- 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.
582 lines
22 KiB
Vue
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>
|