- 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.
423 lines
No EOL
15 KiB
Vue
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> |