web-app/src/modules/wallet/components/ReceiveDialog.vue
padreug 981fc23422 Enhance ReceiveDialog and WalletService for LNURL handling and transaction tagging
- Added functionality to encode LNURL for QR code generation in ReceiveDialog, improving payment link sharing.
- Updated WalletService to include a tag property for transactions, allowing for better categorization and display in WalletPage.
- Enhanced WalletPage to display transaction tags, improving user visibility of transaction details.

These changes improve the user experience by providing clearer payment information and enhancing the functionality of the wallet module.
2025-09-14 23:42:09 +02:00

423 lines
No EOL
15 KiB
Vue

<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { nip19 } from 'nostr-tools'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { QrCode, Copy, Check, RefreshCw, Trash2, Plus } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import type { PayLink } from '../services/WalletService'
interface Props {
open: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Services
const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
// Form validation schema
const formSchema = toTypedSchema(z.object({
description: z.string().min(1, "Description is required").max(200, "Description too long"),
minAmount: z.number().min(1, "Minimum amount must be at least 1 sat"),
maxAmount: z.number().min(1, "Maximum amount must be at least 1 sat"),
username: z.string().optional(),
allowComments: z.boolean().default(false)
}))
// Form setup
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
minAmount: 1,
maxAmount: 100000,
username: '',
allowComments: false
}
})
const { resetForm, values, meta } = form
const isFormValid = computed(() => meta.value.valid)
// State
const activeTab = ref('create')
const selectedPayLink = ref<PayLink | null>(null)
const copiedField = ref<string | null>(null)
const qrCode = ref<string | null>(null)
const isLoadingQR = ref(false)
// Computed
const existingPayLinks = computed(() => walletService?.payLinks?.value || [])
const isCreating = computed(() => walletService?.isCreatingPayLink?.value || false)
const error = computed(() => walletService?.error?.value)
// Methods
const onSubmit = form.handleSubmit(async (formValues) => {
try {
const payLink = await walletService.createReceiveAddress({
description: formValues.description,
minAmount: formValues.minAmount,
maxAmount: formValues.maxAmount,
username: formValues.username || undefined,
allowComments: formValues.allowComments
})
if (payLink) {
selectedPayLink.value = payLink
activeTab.value = 'existing'
resetForm()
toastService?.success('Receive address created successfully!')
}
} catch (error) {
console.error('Failed to create receive address:', error)
}
})
async function copyToClipboard(text: string, field: string) {
try {
await navigator.clipboard.writeText(text)
copiedField.value = field
toastService?.success('Copied to clipboard!')
setTimeout(() => {
copiedField.value = null
}, 2000)
} catch (error) {
console.error('Failed to copy:', error)
toastService?.error('Failed to copy to clipboard')
}
}
function encodeLNURL(url: string): string {
try {
// Convert URL to bytes
const bytes = new TextEncoder().encode(url)
// Encode as bech32 with 'lnurl' prefix
const bech32 = nip19.encodeBytes('lnurl', bytes)
// Return with lightning: prefix in uppercase
return `lightning:${bech32.toUpperCase()}`
} catch (error) {
console.error('Failed to encode LNURL:', error)
return url // Fallback to original URL
}
}
async function generateQRCode(data: string) {
if (!data) return
isLoadingQR.value = true
try {
// Encode LNURL with proper bech32 format and lightning: prefix
const encodedLNURL = encodeLNURL(data)
// Use the existing PaymentService QR code generation
qrCode.value = await paymentService?.generateQRCode(encodedLNURL)
} catch (error) {
console.error('Failed to generate QR code:', error)
toastService?.error('Failed to generate QR code')
} finally {
isLoadingQR.value = false
}
}
async function deletePayLink(link: PayLink) {
if (await walletService.deletePayLink(link.id)) {
toastService?.success('Receive address deleted')
if (selectedPayLink.value?.id === link.id) {
selectedPayLink.value = null
}
}
}
async function selectPayLink(link: PayLink) {
selectedPayLink.value = link
// Generate QR code when selecting a pay link
if (link.lnurl) {
await generateQRCode(link.lnurl)
}
}
function closeDialog() {
emit('update:open', false)
resetForm()
selectedPayLink.value = null
activeTab.value = 'create'
}
// Refresh pay links when dialog opens
function onOpenChange(open: boolean) {
if (open && walletService) {
walletService.refresh()
}
}
</script>
<template>
<Dialog :open="props.open" @update:open="onOpenChange">
<DialogContent class="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<QrCode class="h-5 w-5" />
Receive Bitcoin
</DialogTitle>
<DialogDescription>
Create LNURL addresses and Lightning addresses to receive Bitcoin payments.
</DialogDescription>
</DialogHeader>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="existing">Existing Addresses</TabsTrigger>
</TabsList>
<TabsContent value="create" class="space-y-4">
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Tips for my work"
v-bind="componentField"
/>
</FormControl>
<FormDescription>What are you receiving payments for?</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="minAmount">
<FormItem>
<FormLabel>Min Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="1"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="maxAmount">
<FormItem>
<FormLabel>Max Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="100000"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Lightning Address Username (Optional)</FormLabel>
<FormControl>
<Input
placeholder="e.g., satoshi"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Creates a Lightning address like: satoshi@yourdomain.com</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="allowComments" type="checkbox">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-3">
<div class="space-y-0.5">
<FormLabel>Allow Comments</FormLabel>
<FormDescription>Let senders include a message with their payment</FormDescription>
</div>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<div v-if="error" class="text-destructive text-sm">
{{ error }}
</div>
<div class="flex gap-2 pt-4">
<Button
type="submit"
:disabled="!isFormValid || isCreating"
class="flex-1"
>
<RefreshCw v-if="isCreating" class="w-4 h-4 mr-2 animate-spin" />
<Plus v-else class="w-4 h-4 mr-2" />
{{ isCreating ? 'Creating...' : 'Create Receive Address' }}
</Button>
<Button type="button" variant="outline" @click="closeDialog">
Cancel
</Button>
</div>
</form>
</TabsContent>
<TabsContent value="existing" class="space-y-4">
<div v-if="existingPayLinks.length === 0" class="text-center py-8 text-muted-foreground">
<QrCode class="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No receive addresses created yet</p>
<Button variant="outline" @click="activeTab = 'create'" class="mt-4">
Create Your First Address
</Button>
</div>
<div v-else class="space-y-4">
<!-- Pay Links List -->
<div class="grid gap-2 max-h-48 overflow-y-auto">
<div
v-for="link in existingPayLinks"
:key="link.id"
@click="selectPayLink(link)"
class="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors"
:class="{ 'bg-accent border-primary': selectedPayLink?.id === link.id }"
>
<div class="flex-1">
<p class="font-medium">{{ link.description }}</p>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ link.min }}-{{ link.max }} sats</span>
<Badge v-if="link.username" variant="secondary">{{ link.username }}</Badge>
</div>
</div>
<Button
variant="ghost"
size="sm"
@click.stop="deletePayLink(link)"
class="text-destructive hover:text-destructive"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Selected Pay Link Details -->
<div v-if="selectedPayLink" class="border rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<h4 class="font-medium">{{ selectedPayLink.description }}</h4>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ selectedPayLink.min }}-{{ selectedPayLink.max }} sats</span>
</div>
</div>
<Separator />
<!-- QR Code Display -->
<div class="flex flex-col items-center space-y-4">
<div v-if="isLoadingQR" class="w-48 h-48 flex items-center justify-center bg-muted rounded-lg">
<RefreshCw class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="qrCode" class="bg-white p-4 rounded-lg">
<img
:src="qrCode"
alt="LNURL QR Code"
class="w-48 h-48"
/>
</div>
<div v-else class="w-48 h-48 flex items-center justify-center bg-muted rounded-lg">
<QrCode class="h-12 w-12 text-muted-foreground opacity-50" />
</div>
<!-- Encoded LNURL (for QR) -->
<div class="w-full space-y-2">
<Label>LNURL (Encoded)</Label>
<div class="flex gap-2">
<Input
:value="selectedPayLink.lnurl ? encodeLNURL(selectedPayLink.lnurl) : ''"
readonly
class="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
@click="copyToClipboard(selectedPayLink.lnurl ? encodeLNURL(selectedPayLink.lnurl) : '', 'encoded-lnurl')"
>
<Check v-if="copiedField === 'encoded-lnurl'" class="h-4 w-4 text-green-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Raw LNURL -->
<div class="w-full space-y-2">
<Label>LNURL (Raw)</Label>
<div class="flex gap-2">
<Input
:value="selectedPayLink.lnurl"
readonly
class="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
@click="copyToClipboard(selectedPayLink.lnurl || '', 'lnurl')"
>
<Check v-if="copiedField === 'lnurl'" class="h-4 w-4 text-green-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Lightning Address (if available) -->
<div v-if="selectedPayLink.lnaddress" class="w-full space-y-2">
<Label>Lightning Address</Label>
<div class="flex gap-2">
<Input
:value="selectedPayLink.lnaddress"
readonly
class="font-mono"
/>
<Button
variant="outline"
size="sm"
@click="copyToClipboard(selectedPayLink.lnaddress || '', 'lnaddress')"
>
<Check v-if="copiedField === 'lnaddress'" class="h-4 w-4 text-green-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<Button variant="outline" @click="closeDialog">
Close
</Button>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</template>