- Updated import paths in the wallet module to enhance clarity and maintainability. - Removed unused imports in ReceiveDialog and SendDialog components to streamline the code. - Introduced a computed property in WalletPage to extract the base domain from the payment service configuration, improving readability and error handling. These changes contribute to a cleaner codebase and enhance the overall performance of the wallet module.
430 lines
No EOL
15 KiB
Vue
430 lines
No EOL
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } 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, 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'
|
|
}
|
|
|
|
// Handle dialog open/close state changes
|
|
function onOpenChange(open: boolean) {
|
|
if (open && walletService) {
|
|
walletService.refresh()
|
|
} else if (!open) {
|
|
// Dialog is being closed (including X button) - just emit the update
|
|
emit('update:open', false)
|
|
// Clean up state
|
|
resetForm()
|
|
selectedPayLink.value = null
|
|
activeTab.value = 'create'
|
|
}
|
|
}
|
|
</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> |