webapp/src/modules/wallet/components/SendDialog.vue
padreug 42d16908e1 Enhance SendDialog with payment type detection and dynamic form validation
- Added support for parsing BOLT11 invoices, LNURLs, and Lightning addresses to improve payment destination handling.
- Implemented dynamic form validation schema based on detected payment type, ensuring appropriate fields are required.
- Introduced computed properties for displaying parsed invoice details, including amount and description.
- Enhanced user feedback by conditionally rendering input fields and descriptions based on payment type.

These changes streamline the payment process by providing clearer guidance and validation for different payment methods.
2025-09-20 10:31:12 +02:00

397 lines
No EOL
12 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { decode as decodeBolt11 } from 'light-bolt11-decoder'
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 { Send, AlertCircle, Loader2, ScanLine } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import QRScanner from '@/components/ui/qr-scanner.vue'
interface Props {
open: boolean
initialDestination?: string
}
interface Emits {
(e: 'update:open', value: boolean): void
}
interface ParsedInvoice {
amount: number // in sats
description: string
bolt11: string
paymentHash: string
expiry?: number
}
interface ParsedLNURL {
type: 'lnurl' | 'lightning-address'
minSendable: number // in msats
maxSendable: number // in msats
description: string
}
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
// Payment parsing state
const parsedInvoice = ref<ParsedInvoice | null>(null)
const parsedLNURL = ref<ParsedLNURL | null>(null)
const paymentType = ref<'bolt11' | 'lnurl' | 'lightning-address' | 'unknown'>('unknown')
// Payment type detection functions
function isLNURL(input: string): boolean {
const lower = input.toLowerCase()
return lower.startsWith('lnurl1') ||
lower.startsWith('lnurlp://') ||
lower.startsWith('lnurlw://') ||
lower.startsWith('lnurlauth://')
}
function isLightningAddress(input: string): boolean {
return /^[\w.+-~_]+@[\w.+-~_]+$/.test(input)
}
function isBolt11(input: string): boolean {
const lower = input.toLowerCase()
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
}
function parseBolt11Invoice(bolt11: string): ParsedInvoice | null {
try {
const decoded = decodeBolt11(bolt11)
// Extract amount from millisatoshis
const amountMsat = decoded.sections.find(s => s.name === 'amount')?.value
const amount = amountMsat ? Number(amountMsat) / 1000 : 0
// Extract description
const description = decoded.sections.find(s => s.name === 'description')?.value || ''
// Extract payment hash
const paymentHash = decoded.sections.find(s => s.name === 'payment_hash')?.value || ''
return {
amount,
description,
bolt11,
paymentHash,
}
} catch (error) {
console.error('Failed to decode BOLT11 invoice:', error)
return null
}
}
function parsePaymentDestination(destination: string) {
// Clear previous parsing results
parsedInvoice.value = null
parsedLNURL.value = null
paymentType.value = 'unknown'
if (!destination.trim()) return
const cleanDest = destination.trim()
if (isBolt11(cleanDest)) {
paymentType.value = 'bolt11'
parsedInvoice.value = parseBolt11Invoice(cleanDest)
} else if (isLNURL(cleanDest)) {
paymentType.value = 'lnurl'
// LNURL parsing would require API call to resolve
// For now, just mark as LNURL type
} else if (isLightningAddress(cleanDest)) {
paymentType.value = 'lightning-address'
// Lightning address parsing would require API call
// For now, just mark as lightning address type
}
}
// Dynamic form validation schema based on payment type
const formSchema = computed(() => {
const baseSchema = {
destination: z.string().min(1, "Destination is required"),
comment: z.string().optional()
}
// Only require amount for LNURL, Lightning addresses, or zero-amount invoices
const needsAmountInput = paymentType.value === 'lnurl' ||
paymentType.value === 'lightning-address' ||
(paymentType.value === 'bolt11' && parsedInvoice.value?.amount === 0)
if (needsAmountInput) {
return toTypedSchema(z.object({
...baseSchema,
amount: z.number().min(1, "Amount must be at least 1 sat").max(1000000, "Amount too large")
}))
} else {
return toTypedSchema(z.object(baseSchema))
}
})
// Form setup with dynamic schema
const form = useForm({
validationSchema: formSchema,
initialValues: {
destination: props.initialDestination || '',
amount: 100,
comment: ''
}
})
const { resetForm, values, meta, setFieldValue } = form
const isFormValid = computed(() => meta.value.valid)
// Watch for prop changes
watch(() => props.initialDestination, (newDestination) => {
if (newDestination) {
setFieldValue('destination', newDestination)
}
}, { immediate: true })
// Watch destination changes to parse payment type
watch(() => values.destination, (newDestination) => {
parsePaymentDestination(newDestination)
}, { immediate: true })
// State
const isSending = computed(() => walletService?.isSendingPayment?.value || false)
const showScanner = ref(false)
const error = computed(() => walletService?.error?.value)
// Computed properties for UI logic
const showAmountField = computed(() => {
return paymentType.value === 'lnurl' ||
paymentType.value === 'lightning-address' ||
(paymentType.value === 'bolt11' && parsedInvoice.value?.amount === 0)
})
const displayAmount = computed(() => {
if (parsedInvoice.value && parsedInvoice.value.amount > 0) {
return parsedInvoice.value.amount
}
return null
})
const displayDescription = computed(() => {
return parsedInvoice.value?.description || ''
})
const effectiveAmount = computed(() => {
// Use parsed invoice amount if available, otherwise use form input
if (parsedInvoice.value && parsedInvoice.value.amount > 0) {
return parsedInvoice.value.amount
}
return values.amount
})
// Methods
const onSubmit = form.handleSubmit(async (formValues) => {
try {
const success = await walletService.sendPayment({
destination: formValues.destination,
amount: effectiveAmount.value, // Use computed effective amount
comment: formValues.comment || undefined
})
if (success) {
toastService?.success('Payment sent successfully!')
closeDialog()
}
} catch (error) {
console.error('Failed to send payment:', error)
}
})
function closeDialog() {
emit('update:open', false)
resetForm()
showScanner.value = false
}
// QR Scanner functions
function openScanner() {
showScanner.value = true
}
function closeScanner() {
showScanner.value = false
}
function handleScanResult(result: string) {
// Clean up the scanned result by removing lightning: prefix if present
let cleanedResult = result
if (result.toLowerCase().startsWith('lightning:')) {
cleanedResult = result.substring(10) // Remove "lightning:" prefix
}
// Set the cleaned result in the destination field
setFieldValue('destination', cleanedResult)
closeScanner()
toastService?.success('QR code scanned successfully!')
}
// Determine destination type helper text
const destinationType = computed(() => {
switch (paymentType.value) {
case 'bolt11':
if (parsedInvoice.value) {
const amountText = parsedInvoice.value.amount > 0 ?
` (${parsedInvoice.value.amount.toLocaleString()} sats)` : ' (zero amount)'
return `Lightning Invoice${amountText}`
}
return 'Lightning Invoice'
case 'lightning-address':
return 'Lightning Address'
case 'lnurl':
return 'LNURL'
default:
return ''
}
})
</script>
<template>
<Dialog :open="props.open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Send class="h-5 w-5" />
Send Bitcoin
</DialogTitle>
<DialogDescription>
Send Bitcoin via Lightning Network
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="destination">
<FormItem>
<div class="flex items-center justify-between">
<FormLabel>Destination *</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
@click="openScanner"
class="h-7 px-2 text-xs"
>
<ScanLine class="w-3 h-3 mr-1" />
Scan QR
</Button>
</div>
<FormControl>
<Textarea
placeholder="Lightning invoice, LNURL, or Lightning address (user@domain.com)"
v-bind="componentField"
class="min-h-[80px] font-mono text-xs"
/>
</FormControl>
<FormDescription v-if="destinationType">
Detected: {{ destinationType }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Parsed Invoice Display (BOLT11 with fixed amount) -->
<div v-if="parsedInvoice && displayAmount" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium">Lightning Invoice</h4>
<div class="text-lg font-bold text-green-600">
{{ displayAmount.toLocaleString() }} sats
</div>
</div>
<div v-if="displayDescription" class="text-sm text-muted-foreground">
<strong>Description:</strong> {{ displayDescription }}
</div>
</div>
<!-- Amount Input Field (conditional) -->
<FormField v-if="showAmountField" v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="100"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
<span v-if="paymentType === 'lightning-address'">Amount to send to Lightning address</span>
<span v-else-if="paymentType === 'lnurl'">Amount to send via LNURL</span>
<span v-else>Amount to send in satoshis</span>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="comment">
<FormItem>
<FormLabel>Comment (Optional)</FormLabel>
<FormControl>
<Input
placeholder="Optional message with payment"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Add a note to your payment (if supported)</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div v-if="error" class="flex items-start gap-2 p-3 bg-destructive/15 text-destructive rounded-lg">
<AlertCircle class="h-4 w-4 mt-0.5" />
<span class="text-sm">{{ error }}</span>
</div>
<div class="flex gap-2 pt-4">
<Button
type="submit"
:disabled="!isFormValid || isSending"
class="flex-1"
>
<Loader2 v-if="isSending" class="w-4 h-4 mr-2 animate-spin" />
<Send v-else class="w-4 h-4 mr-2" />
{{ isSending ? 'Sending...' : 'Send Payment' }}
</Button>
<Button type="button" variant="outline" @click="closeDialog" :disabled="isSending">
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<!-- QR Scanner Dialog -->
<Dialog :open="showScanner" @update:open="showScanner = $event">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ScanLine class="h-5 w-5" />
Scan QR Code
</DialogTitle>
<DialogDescription>
Point your camera at a Lightning invoice QR code
</DialogDescription>
</DialogHeader>
<QRScanner
@result="handleScanResult"
@close="closeScanner"
/>
</DialogContent>
</Dialog>
</template>