web-app/src/modules/wallet/views/WalletPage.vue
padreug f94dc1d03c Enhance SendDialog and WalletPage with QR code scanning integration
- Added initialDestination prop to SendDialog for pre-filling the destination field.
- Implemented a watcher to update the destination field when initialDestination changes.
- Integrated QRScanner component into WalletPage, allowing users to scan QR codes for payment destinations.
- Updated SendDialog to accept scanned destination and improved user feedback with toast notifications.

These changes streamline the payment process by enabling QR code scanning directly within the wallet interface.
2025-09-18 23:02:56 +02:00

582 lines
22 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { RefreshCw, Send, QrCode, ArrowUpRight, ArrowDownLeft, Clock, Wallet, ScanLine, Copy, Check } from 'lucide-vue-next'
import ReceiveDialog from '../components/ReceiveDialog.vue'
import SendDialog from '../components/SendDialog.vue'
import QRScanner from '@/components/ui/qr-scanner.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 showQRScanner = ref(false)
const scannedDestination = ref<string>('')
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')
}
// QR Scanner functions
function closeQRScanner() {
showQRScanner.value = false
}
function handleQRScanResult(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
}
// Store the scanned result and close QR scanner
scannedDestination.value = cleanedResult
closeQRScanner()
// Open send dialog with the scanned result
showSendDialog.value = true
toastService?.success('QR code scanned successfully!')
}
// 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>
<Button
variant="outline"
@click="showQRScanner = true"
class="h-12 w-12 p-0"
title="Scan QR Code"
>
<ScanLine class="h-5 w-5" />
</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"
:initial-destination="scannedDestination"
@update:open="(open) => {
showSendDialog = open
if (!open) scannedDestination = ''
}"
/>
<!-- QR Scanner Dialog -->
<Dialog :open="showQRScanner" @update:open="showQRScanner = $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 or payment QR code
</DialogDescription>
</DialogHeader>
<QRScanner
@result="handleQRScanResult"
@close="closeQRScanner"
/>
</DialogContent>
</Dialog>
</div>
</template>