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.
This commit is contained in:
parent
a5f800ef74
commit
42d16908e1
1 changed files with 177 additions and 18 deletions
|
|
@ -3,6 +3,7 @@ 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'
|
||||
|
|
@ -21,6 +22,21 @@ 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>()
|
||||
|
||||
|
|
@ -28,14 +44,102 @@ const emit = defineEmits<Emits>()
|
|||
const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
|
||||
const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any
|
||||
|
||||
// Form validation schema
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
destination: z.string().min(1, "Destination is required"),
|
||||
amount: z.number().min(1, "Amount must be at least 1 sat").max(1000000, "Amount too large"),
|
||||
comment: z.string().optional()
|
||||
}))
|
||||
// Payment parsing state
|
||||
const parsedInvoice = ref<ParsedInvoice | null>(null)
|
||||
const parsedLNURL = ref<ParsedLNURL | null>(null)
|
||||
const paymentType = ref<'bolt11' | 'lnurl' | 'lightning-address' | 'unknown'>('unknown')
|
||||
|
||||
// Form setup
|
||||
// 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: {
|
||||
|
|
@ -55,17 +159,48 @@ watch(() => props.initialDestination, (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: formValues.amount,
|
||||
amount: effectiveAmount.value, // Use computed effective amount
|
||||
comment: formValues.comment || undefined
|
||||
})
|
||||
|
||||
|
|
@ -108,15 +243,21 @@ function handleScanResult(result: string) {
|
|||
|
||||
// Determine destination type helper text
|
||||
const destinationType = computed(() => {
|
||||
const dest = values.destination?.toLowerCase() || ''
|
||||
if (dest.startsWith('lnbc') || dest.startsWith('lntb')) {
|
||||
return 'Lightning Invoice'
|
||||
} else if (dest.includes('@')) {
|
||||
return 'Lightning Address'
|
||||
} else if (dest.startsWith('lnurl')) {
|
||||
return 'LNURL'
|
||||
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 ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -163,7 +304,21 @@ const destinationType = computed(() => {
|
|||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="amount">
|
||||
<!-- 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>
|
||||
|
|
@ -174,7 +329,11 @@ const destinationType = computed(() => {
|
|||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Amount to send in satoshis</FormDescription>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue