Add wallet module with receive and send functionality

- Introduced a new wallet module that includes components for sending and receiving Bitcoin payments.
- Implemented WalletService to manage payment links and transactions, including methods for creating LNURL pay links and sending payments.
- Added dialogs for receiving and sending payments, enhancing user interaction with the wallet.
- Updated app configuration to enable the wallet module and integrated it into the main application flow.

These changes provide users with a comprehensive wallet experience, allowing for seamless Bitcoin transactions.
This commit is contained in:
padreug 2025-09-14 23:08:01 +02:00
parent c74945874c
commit f75aae6be6
12 changed files with 1294 additions and 3 deletions

View file

@ -0,0 +1,304 @@
<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 { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { RefreshCw, Send, QrCode, ArrowUpRight, ArrowDownLeft, Clock, Wallet } from 'lucide-vue-next'
import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue'
import ReceiveDialog from '../components/ReceiveDialog.vue'
import SendDialog from '../components/SendDialog.vue'
import { format } from 'date-fns'
// 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
// State
const showReceiveDialog = ref(false)
const showSendDialog = ref(false)
const selectedTab = ref('transactions')
// Computed
const transactions = computed(() => walletService?.transactions?.value || [])
const isLoading = computed(() => walletService?.isLoading?.value || false)
const error = computed(() => walletService?.error?.value)
const currentUser = computed(() => authService?.currentUser?.value)
const totalBalance = computed(() => {
if (!currentUser.value?.wallets) return 0
return currentUser.value.wallets.reduce((total: number, wallet: any) => {
return total + (wallet.balance_msat || 0)
}, 0)
})
// Get transactions grouped by date
const groupedTransactions = computed(() => {
const groups: Record<string, typeof transactions.value> = {}
transactions.value.forEach((tx: any) => {
const dateKey = format(tx.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?.refreshUserData()
}
function getTransactionIcon(type: string) {
return type === 'received' ? ArrowDownLeft : ArrowUpRight
}
function getTransactionColor(type: string) {
return type === 'received' ? 'text-green-600' : 'text-orange-600'
}
function getStatusColor(status: string) {
switch (status) {
case 'confirmed':
return 'default'
case 'pending':
return 'secondary'
case 'failed':
return 'destructive'
default:
return 'outline'
}
}
// Initialize on mount
onMounted(() => {
refresh()
})
</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 items-center justify-between">
<div class="text-3xl font-bold">
<CurrencyDisplay :balance-msat="totalBalance" />
</div>
<div class="flex gap-2">
<Button
variant="default"
@click="showReceiveDialog = true"
class="gap-2"
>
<QrCode class="h-4 w-4" />
Receive
</Button>
<Button
variant="outline"
@click="showSendDialog = true"
class="gap-2"
>
<Send class="h-4 w-4" />
Send
</Button>
</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-2">
<div
v-for="tx in txs"
:key="tx.id"
class="flex items-center justify-between p-4 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-4">
<div
class="p-2 rounded-full bg-background border"
:class="getTransactionColor(tx.type)"
>
<component
:is="getTransactionIcon(tx.type)"
class="h-4 w-4"
/>
</div>
<div>
<p class="font-medium">{{ tx.description }}</p>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ format(tx.timestamp, 'HH:mm') }}</span>
<Badge :variant="getStatusColor(tx.status)" class="text-xs">
{{ tx.status }}
</Badge>
</div>
</div>
</div>
<div class="text-right">
<p
class="font-semibold"
:class="getTransactionColor(tx.type)"
>
{{ tx.type === 'received' ? '+' : '-' }}
{{ tx.amount.toLocaleString() }} sats
</p>
<p v-if="tx.fee" class="text-xs text-muted-foreground">
Fee: {{ tx.fee }} sats
</p>
</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 }}@{{ new URL(paymentService?.config?.baseUrl || 'http://localhost').hostname }}
</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>