- 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.
397 lines
No EOL
12 KiB
Vue
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> |