- Updated the form to create Lightning invoices instead of LNURL addresses, changing the validation schema and input fields accordingly. - Introduced new state management for created invoices and adjusted the submission logic to handle invoice creation. - Enhanced the UI to display invoice details, including amount, memo, and QR code generation for the invoice. - Removed unused components and streamlined the dialog's functionality for a more focused user experience. These changes improve the functionality and user interface of the ReceiveDialog component, facilitating easier invoice management for Bitcoin payments.
526 lines
20 KiB
Vue
526 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { RefreshCw, Send, QrCode, ArrowUpRight, ArrowDownLeft, Clock, Wallet, Copy, Check } from 'lucide-vue-next'
|
|
import ReceiveDialog from '../components/ReceiveDialog.vue'
|
|
import SendDialog from '../components/SendDialog.vue'
|
|
import { format } from 'date-fns'
|
|
import { nip19 } from 'nostr-tools'
|
|
|
|
// Services
|
|
const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
|
|
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
|
const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any
|
|
|
|
// State
|
|
const showReceiveDialog = ref(false)
|
|
const showSendDialog = ref(false)
|
|
const selectedTab = ref('transactions')
|
|
const defaultQrCode = ref<string | null>(null)
|
|
const isGeneratingQR = ref(false)
|
|
const copiedField = ref<string | null>(null)
|
|
|
|
// Computed
|
|
const transactions = computed(() => walletService?.transactions?.value || [])
|
|
const isLoading = computed(() => walletService?.isLoading?.value || false)
|
|
const error = computed(() => walletService?.error?.value)
|
|
// Use PaymentService for centralized balance calculation
|
|
const totalBalance = computed(() => paymentService?.totalBalance || 0)
|
|
|
|
const payLinks = computed(() => walletService?.payLinks?.value || [])
|
|
const firstPayLink = computed(() => payLinks.value[0] || null)
|
|
const baseDomain = computed(() => {
|
|
try {
|
|
const baseUrl = paymentService?.config?.baseUrl || 'http://localhost'
|
|
return new URL(baseUrl).hostname
|
|
} catch {
|
|
return 'localhost'
|
|
}
|
|
})
|
|
|
|
// Get transactions grouped by date
|
|
const groupedTransactions = computed(() => {
|
|
const groups: Record<string, typeof transactions.value> = {}
|
|
|
|
transactions.value.forEach((tx: any) => {
|
|
// Ensure timestamp is valid before formatting
|
|
const timestamp = tx.timestamp instanceof Date && !isNaN(tx.timestamp.getTime())
|
|
? tx.timestamp
|
|
: new Date()
|
|
|
|
const dateKey = format(timestamp, 'MMMM d, yyyy')
|
|
if (!groups[dateKey]) {
|
|
groups[dateKey] = []
|
|
}
|
|
groups[dateKey].push(tx)
|
|
})
|
|
|
|
return groups
|
|
})
|
|
|
|
// Methods
|
|
async function refresh() {
|
|
await walletService?.refresh()
|
|
// Also refresh auth data to update balance
|
|
await authService?.refresh()
|
|
// Regenerate QR if pay link is available
|
|
if (firstPayLink.value) {
|
|
await generateDefaultQR()
|
|
}
|
|
}
|
|
|
|
function getTransactionIcon(type: string, status: string) {
|
|
if (status === 'pending') {
|
|
return Clock
|
|
}
|
|
return type === 'received' ? ArrowDownLeft : ArrowUpRight
|
|
}
|
|
|
|
function getTransactionColor(type: string, status: string) {
|
|
if (status === 'pending') {
|
|
return 'text-amber-500'
|
|
}
|
|
return type === 'received' ? 'text-green-600' : 'text-red-600'
|
|
}
|
|
|
|
|
|
// QR Code generation
|
|
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 generateDefaultQR() {
|
|
if (!firstPayLink.value?.lnurl) return
|
|
|
|
isGeneratingQR.value = true
|
|
try {
|
|
// Encode LNURL with proper bech32 format and lightning: prefix
|
|
const encodedLNURL = encodeLNURL(firstPayLink.value.lnurl)
|
|
// Use the existing PaymentService QR code generation
|
|
defaultQrCode.value = await paymentService?.generateQRCode(encodedLNURL)
|
|
} catch (error) {
|
|
console.error('Failed to generate default QR code:', error)
|
|
} finally {
|
|
isGeneratingQR.value = false
|
|
}
|
|
}
|
|
|
|
// Copy functionality
|
|
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')
|
|
}
|
|
}
|
|
|
|
// Click handler for QR code to copy LNURL
|
|
function handleQRClick() {
|
|
if (firstPayLink.value?.lnurl) {
|
|
// Encode LNURL with proper bech32 format and lightning: prefix (same as QR code)
|
|
const encodedLNURL = encodeLNURL(firstPayLink.value.lnurl)
|
|
copyToClipboard(encodedLNURL, 'qr-lnurl')
|
|
}
|
|
}
|
|
|
|
// Initialize on mount
|
|
onMounted(async () => {
|
|
await refresh()
|
|
// Generate QR for first pay link if available
|
|
if (firstPayLink.value) {
|
|
await generateDefaultQR()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container mx-auto py-8 px-4 max-w-6xl">
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-3xl font-bold flex items-center gap-2">
|
|
<Wallet class="h-8 w-8" />
|
|
Wallet
|
|
</h1>
|
|
<p class="text-muted-foreground mt-1">Manage your Bitcoin transactions</p>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="refresh"
|
|
:disabled="isLoading"
|
|
>
|
|
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': isLoading }" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Balance Card -->
|
|
<Card class="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Total Balance</CardTitle>
|
|
<CardDescription>Available across all your wallets</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div class="flex flex-col gap-6">
|
|
<!-- Balance Section -->
|
|
<div class="text-center sm:text-left">
|
|
<div class="text-4xl sm:text-3xl font-bold">
|
|
{{ Math.floor(totalBalance / 1000).toLocaleString() }} <span class="text-2xl sm:text-xl text-muted-foreground font-normal">sats</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex gap-3 w-full sm:w-auto">
|
|
<Button
|
|
variant="default"
|
|
@click="showReceiveDialog = true"
|
|
class="gap-2 flex-1 sm:flex-none h-12 text-base"
|
|
>
|
|
<QrCode class="h-5 w-5" />
|
|
Receive
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
@click="showSendDialog = true"
|
|
class="gap-2 flex-1 sm:flex-none h-12 text-base"
|
|
>
|
|
<Send class="h-5 w-5" />
|
|
Send
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Quick Receive QR Code -->
|
|
<Card v-if="firstPayLink" class="mb-6">
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2">
|
|
<QrCode class="h-5 w-5" />
|
|
Quick Receive
|
|
</CardTitle>
|
|
<CardDescription>{{ firstPayLink.description }}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div class="flex flex-col sm:flex-row items-center gap-6">
|
|
<!-- QR Code -->
|
|
<div class="flex-shrink-0">
|
|
<div v-if="isGeneratingQR" 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="defaultQrCode" class="text-center">
|
|
<div class="bg-white p-4 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors" @click="handleQRClick" title="Click to copy LNURL">
|
|
<img
|
|
:src="defaultQrCode"
|
|
alt="LNURL QR Code"
|
|
class="w-48 h-48"
|
|
/>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground mt-2">
|
|
Click QR code to copy LNURL
|
|
</p>
|
|
</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>
|
|
</div>
|
|
|
|
<!-- Address Info -->
|
|
<div class="flex-1 space-y-4 w-full">
|
|
<div>
|
|
<h4 class="font-medium mb-2">Payment Range</h4>
|
|
<div class="text-2xl font-bold text-green-600">
|
|
{{ firstPayLink.min?.toLocaleString() }} - {{ firstPayLink.max?.toLocaleString() }} sats
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="firstPayLink.lnaddress">
|
|
<h4 class="font-medium mb-2">Lightning Address</h4>
|
|
<div class="flex gap-2">
|
|
<div class="font-mono text-sm bg-muted px-3 py-2 rounded flex-1">
|
|
{{ firstPayLink.lnaddress }}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="copyToClipboard(firstPayLink.lnaddress || '', 'lightning-address')"
|
|
class="h-auto px-2"
|
|
title="Copy Lightning Address"
|
|
>
|
|
<Check v-if="copiedField === 'lightning-address'" class="h-4 w-4 text-green-600" />
|
|
<Copy v-else class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Tabs -->
|
|
<Tabs v-model="selectedTab" class="w-full">
|
|
<TabsList class="grid w-full grid-cols-2">
|
|
<TabsTrigger value="transactions">Transaction History</TabsTrigger>
|
|
<TabsTrigger value="addresses">Receive Addresses</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<!-- Transactions Tab -->
|
|
<TabsContent value="transactions" class="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recent Transactions</CardTitle>
|
|
<CardDescription>Your payment history</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="error" class="text-center py-8 text-destructive">
|
|
{{ error }}
|
|
</div>
|
|
|
|
<div v-else-if="transactions.length === 0" class="text-center py-12 text-muted-foreground">
|
|
<Clock class="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No transactions yet</p>
|
|
<p class="text-sm mt-2">Your transaction history will appear here</p>
|
|
</div>
|
|
|
|
<ScrollArea v-else class="h-[500px] pr-4">
|
|
<div v-for="(txs, date) in groupedTransactions" :key="date" class="mb-6">
|
|
<div class="text-sm font-medium text-muted-foreground mb-3">{{ date }}</div>
|
|
|
|
<div class="space-y-4">
|
|
<div
|
|
v-for="tx in txs"
|
|
:key="tx.id"
|
|
class="relative p-3 rounded-lg border hover:bg-accent/50 transition-colors"
|
|
>
|
|
<!-- Tag badge in top-left corner -->
|
|
<Badge v-if="tx.tag" variant="secondary" class="absolute -top-2.75 left-11 text-xs font-medium z-10 bg-blue-100 text-blue-800 border-blue-200 pointer-events-none">
|
|
{{ tx.tag }}
|
|
</Badge>
|
|
<!-- Mobile Layout: Stack vertically -->
|
|
<div class="block sm:hidden">
|
|
<!-- Header Row: Icon, Amount, Type -->
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="p-1.5 rounded-full bg-background border"
|
|
:class="getTransactionColor(tx.type, tx.status)"
|
|
>
|
|
<component
|
|
:is="getTransactionIcon(tx.type, tx.status)"
|
|
class="h-4 w-4"
|
|
/>
|
|
</div>
|
|
<span class="text-sm text-muted-foreground">{{
|
|
(() => {
|
|
try {
|
|
if (tx.timestamp instanceof Date && !isNaN(tx.timestamp.getTime())) {
|
|
return format(tx.timestamp, 'HH:mm')
|
|
} else if (tx.timestamp) {
|
|
// Try to parse as string or number
|
|
const date = new Date(tx.timestamp)
|
|
if (!isNaN(date.getTime())) {
|
|
return format(date, 'HH:mm')
|
|
}
|
|
}
|
|
return '--:--'
|
|
} catch (error) {
|
|
console.warn('Failed to format timestamp:', tx.timestamp, error)
|
|
return '--:--'
|
|
}
|
|
})()
|
|
}}</span>
|
|
</div>
|
|
|
|
<div class="text-right">
|
|
<p
|
|
class="font-semibold text-base"
|
|
:class="getTransactionColor(tx.type, tx.status)"
|
|
>
|
|
{{ tx.type === 'received' ? '+' : '-' }}{{ tx.amount.toLocaleString() }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground">sats</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description Row -->
|
|
<div class="mb-2">
|
|
<p class="font-medium text-sm leading-tight break-words pr-2">{{ tx.description }}</p>
|
|
</div>
|
|
|
|
<!-- Badges Row -->
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span v-if="tx.fee" class="text-xs text-muted-foreground">
|
|
Fee: {{ tx.fee }} sats
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Desktop Layout: Better space management -->
|
|
<div class="hidden sm:flex items-start gap-4">
|
|
<div class="flex items-center gap-3 flex-shrink-0">
|
|
<div
|
|
class="p-2 rounded-full bg-background border"
|
|
:class="getTransactionColor(tx.type, tx.status)"
|
|
>
|
|
<component
|
|
:is="getTransactionIcon(tx.type, tx.status)"
|
|
class="h-4 w-4"
|
|
/>
|
|
</div>
|
|
<span class="text-sm text-muted-foreground">{{
|
|
(() => {
|
|
try {
|
|
if (tx.timestamp instanceof Date && !isNaN(tx.timestamp.getTime())) {
|
|
return format(tx.timestamp, 'HH:mm')
|
|
} else if (tx.timestamp) {
|
|
// Try to parse as string or number
|
|
const date = new Date(tx.timestamp)
|
|
if (!isNaN(date.getTime())) {
|
|
return format(date, 'HH:mm')
|
|
}
|
|
}
|
|
return '--:--'
|
|
} catch (error) {
|
|
console.warn('Failed to format timestamp:', tx.timestamp, error)
|
|
return '--:--'
|
|
}
|
|
})()
|
|
}}</span>
|
|
</div>
|
|
|
|
<!-- Description with flexible width -->
|
|
<div class="min-w-0 flex-1">
|
|
<p class="font-medium break-words leading-tight pr-2">{{ tx.description }}</p>
|
|
<div v-if="tx.fee" class="text-xs text-muted-foreground mt-1">
|
|
Fee: {{ tx.fee }} sats
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Amount - fixed width, always visible -->
|
|
<div class="text-right flex-shrink-0 min-w-[100px]">
|
|
<p
|
|
class="font-semibold whitespace-nowrap"
|
|
:class="getTransactionColor(tx.type, tx.status)"
|
|
>
|
|
{{ tx.type === 'received' ? '+' : '-' }}{{ tx.amount.toLocaleString() }} sats
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<!-- Addresses Tab -->
|
|
<TabsContent value="addresses" class="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Receive Addresses</CardTitle>
|
|
<CardDescription>Your LNURL and Lightning addresses for receiving payments</CardDescription>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
@click="showReceiveDialog = true"
|
|
>
|
|
<QrCode class="h-4 w-4 mr-2" />
|
|
New Address
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="walletService?.payLinks?.value?.length === 0" class="text-center py-12 text-muted-foreground">
|
|
<QrCode class="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No receive addresses created</p>
|
|
<p class="text-sm mt-2">Create an address to start receiving payments</p>
|
|
<Button
|
|
variant="outline"
|
|
class="mt-4"
|
|
@click="showReceiveDialog = true"
|
|
>
|
|
Create Your First Address
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div
|
|
v-for="link in walletService?.payLinks?.value"
|
|
:key="link.id"
|
|
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
|
>
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<p class="font-medium">{{ link.description }}</p>
|
|
<div class="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
|
<span>{{ link.min }}-{{ link.max }} sats</span>
|
|
<Badge v-if="link.username" variant="secondary">
|
|
{{ link.username }}@{{ baseDomain }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div class="mt-3 space-y-2">
|
|
<div v-if="link.lnaddress" class="flex items-center gap-2">
|
|
<span class="text-xs text-muted-foreground">Lightning Address:</span>
|
|
<code class="text-xs bg-muted px-2 py-1 rounded">{{ link.lnaddress }}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="showReceiveDialog = true"
|
|
>
|
|
<QrCode class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<!-- Dialogs -->
|
|
<ReceiveDialog
|
|
:open="showReceiveDialog"
|
|
@update:open="showReceiveDialog = $event"
|
|
/>
|
|
|
|
<SendDialog
|
|
v-if="showSendDialog"
|
|
:open="showSendDialog"
|
|
@update:open="showSendDialog = $event"
|
|
/>
|
|
</div>
|
|
</template>
|