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

@ -64,6 +64,18 @@ export const appConfig: AppConfig = {
ticketValidationEndpoint: '/api/tickets/validate', ticketValidationEndpoint: '/api/tickets/validate',
maxTicketsPerUser: 10 maxTicketsPerUser: 10
} }
},
wallet: {
name: 'wallet',
enabled: true,
lazy: false,
config: {
defaultReceiveAmount: 1000, // 1000 sats
maxReceiveAmount: 1000000, // 1M sats
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
}
}
} }
}, },

View file

@ -15,6 +15,7 @@ import nostrFeedModule from './modules/nostr-feed'
import chatModule from './modules/chat' import chatModule from './modules/chat'
import eventsModule from './modules/events' import eventsModule from './modules/events'
import marketModule from './modules/market' import marketModule from './modules/market'
import walletModule from './modules/wallet'
// Root component // Root component
import App from './App.vue' import App from './App.vue'
@ -41,7 +42,8 @@ export async function createAppInstance() {
...nostrFeedModule.routes || [], ...nostrFeedModule.routes || [],
...chatModule.routes || [], ...chatModule.routes || [],
...eventsModule.routes || [], ...eventsModule.routes || [],
...marketModule.routes || [] ...marketModule.routes || [],
...walletModule.routes || []
].filter(Boolean) ].filter(Boolean)
// Create router with all routes available immediately // Create router with all routes available immediately
@ -117,6 +119,13 @@ export async function createAppInstance() {
) )
} }
// Register wallet module
if (appConfig.modules.wallet?.enabled) {
moduleRegistrations.push(
pluginManager.register(walletModule, appConfig.modules.wallet)
)
}
// Wait for all modules to register // Wait for all modules to register
await Promise.all(moduleRegistrations) await Promise.all(moduleRegistrations)

View file

@ -155,10 +155,10 @@ const handleLogout = async () => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 lg:w-64"> <DropdownMenuContent align="end" class="w-56 lg:w-64">
<!-- Wallet Balance in Dropdown --> <!-- Wallet Balance in Dropdown -->
<div class="flex items-center gap-2 px-2 py-1.5 text-sm border-b"> <DropdownMenuItem @click="() => router.push('/wallet')" class="gap-2 border-b">
<Wallet class="h-4 w-4 text-muted-foreground" /> <Wallet class="h-4 w-4 text-muted-foreground" />
<CurrencyDisplay :balance-msat="totalBalance" /> <CurrencyDisplay :balance-msat="totalBalance" />
</div> </DropdownMenuItem>
<DropdownMenuItem @click="openProfileDialog" class="gap-2"> <DropdownMenuItem @click="openProfileDialog" class="gap-2">
<User class="h-4 w-4" /> <User class="h-4 w-4" />

View file

@ -142,6 +142,9 @@ export const SERVICE_TOKENS = {
NOSTRMARKET_SERVICE: Symbol('nostrmarketService'), NOSTRMARKET_SERVICE: Symbol('nostrmarketService'),
NOSTRMARKET_API: Symbol('nostrmarketAPI'), NOSTRMARKET_API: Symbol('nostrmarketAPI'),
// Wallet services
WALLET_SERVICE: Symbol('walletService'),
// API services // API services
LNBITS_API: Symbol('lnbitsAPI'), LNBITS_API: Symbol('lnbitsAPI'),
} as const } as const

View file

@ -100,6 +100,14 @@ export class PaymentService extends BaseService {
return wallet?.adminkey || null return wallet?.adminkey || null
} }
/**
* Get the preferred wallet's invoice key for read-only operations
*/
getPreferredWalletInvoiceKey(): string | null {
const wallet = this.getPreferredWallet()
return wallet?.inkey || null
}
/** /**
* Generate QR code for Lightning payment request * Generate QR code for Lightning payment request
*/ */

View file

@ -0,0 +1,386 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { QrCode, Copy, Check, RefreshCw, Trash2, Plus } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import type { PayLink } from '../services/WalletService'
interface Props {
open: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Services
const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
// Form validation schema
const formSchema = toTypedSchema(z.object({
description: z.string().min(1, "Description is required").max(200, "Description too long"),
minAmount: z.number().min(1, "Minimum amount must be at least 1 sat"),
maxAmount: z.number().min(1, "Maximum amount must be at least 1 sat"),
username: z.string().optional(),
allowComments: z.boolean().default(false)
}))
// Form setup
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
minAmount: 1,
maxAmount: 100000,
username: '',
allowComments: false
}
})
const { resetForm, values, meta } = form
const isFormValid = computed(() => meta.value.valid)
// State
const activeTab = ref('create')
const selectedPayLink = ref<PayLink | null>(null)
const copiedField = ref<string | null>(null)
const qrCode = ref<string | null>(null)
const isLoadingQR = ref(false)
// Computed
const existingPayLinks = computed(() => walletService?.payLinks?.value || [])
const isCreating = computed(() => walletService?.isCreatingPayLink?.value || false)
const error = computed(() => walletService?.error?.value)
// Methods
const onSubmit = form.handleSubmit(async (formValues) => {
try {
const payLink = await walletService.createReceiveAddress({
description: formValues.description,
minAmount: formValues.minAmount,
maxAmount: formValues.maxAmount,
username: formValues.username || undefined,
allowComments: formValues.allowComments
})
if (payLink) {
selectedPayLink.value = payLink
activeTab.value = 'existing'
resetForm()
toastService?.success('Receive address created successfully!')
}
} catch (error) {
console.error('Failed to create receive address:', error)
}
})
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')
}
}
async function generateQRCode(data: string) {
if (!data) return
isLoadingQR.value = true
try {
// Use the existing PaymentService QR code generation
qrCode.value = await paymentService?.generateQRCode(data)
} catch (error) {
console.error('Failed to generate QR code:', error)
toastService?.error('Failed to generate QR code')
} finally {
isLoadingQR.value = false
}
}
async function deletePayLink(link: PayLink) {
if (await walletService.deletePayLink(link.id)) {
toastService?.success('Receive address deleted')
if (selectedPayLink.value?.id === link.id) {
selectedPayLink.value = null
}
}
}
async function selectPayLink(link: PayLink) {
selectedPayLink.value = link
// Generate QR code when selecting a pay link
if (link.lnurl) {
await generateQRCode(link.lnurl)
}
}
function closeDialog() {
emit('update:open', false)
resetForm()
selectedPayLink.value = null
activeTab.value = 'create'
}
// Refresh pay links when dialog opens
function onOpenChange(open: boolean) {
if (open && walletService) {
walletService.refresh()
}
}
</script>
<template>
<Dialog :open="props.open" @update:open="onOpenChange">
<DialogContent class="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<QrCode class="h-5 w-5" />
Receive Bitcoin
</DialogTitle>
<DialogDescription>
Create LNURL addresses and Lightning addresses to receive Bitcoin payments.
</DialogDescription>
</DialogHeader>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="existing">Existing Addresses</TabsTrigger>
</TabsList>
<TabsContent value="create" class="space-y-4">
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Tips for my work"
v-bind="componentField"
/>
</FormControl>
<FormDescription>What are you receiving payments for?</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="minAmount">
<FormItem>
<FormLabel>Min Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="1"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="maxAmount">
<FormItem>
<FormLabel>Max Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="100000"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Lightning Address Username (Optional)</FormLabel>
<FormControl>
<Input
placeholder="e.g., satoshi"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Creates a Lightning address like: satoshi@yourdomain.com</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="allowComments" type="checkbox">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-3">
<div class="space-y-0.5">
<FormLabel>Allow Comments</FormLabel>
<FormDescription>Let senders include a message with their payment</FormDescription>
</div>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<div v-if="error" class="text-destructive text-sm">
{{ error }}
</div>
<div class="flex gap-2 pt-4">
<Button
type="submit"
:disabled="!isFormValid || isCreating"
class="flex-1"
>
<RefreshCw v-if="isCreating" class="w-4 h-4 mr-2 animate-spin" />
<Plus v-else class="w-4 h-4 mr-2" />
{{ isCreating ? 'Creating...' : 'Create Receive Address' }}
</Button>
<Button type="button" variant="outline" @click="closeDialog">
Cancel
</Button>
</div>
</form>
</TabsContent>
<TabsContent value="existing" class="space-y-4">
<div v-if="existingPayLinks.length === 0" class="text-center py-8 text-muted-foreground">
<QrCode class="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No receive addresses created yet</p>
<Button variant="outline" @click="activeTab = 'create'" class="mt-4">
Create Your First Address
</Button>
</div>
<div v-else class="space-y-4">
<!-- Pay Links List -->
<div class="grid gap-2 max-h-48 overflow-y-auto">
<div
v-for="link in existingPayLinks"
:key="link.id"
@click="selectPayLink(link)"
class="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors"
:class="{ 'bg-accent border-primary': selectedPayLink?.id === link.id }"
>
<div class="flex-1">
<p class="font-medium">{{ link.description }}</p>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ link.min }}-{{ link.max }} sats</span>
<Badge v-if="link.username" variant="secondary">{{ link.username }}</Badge>
</div>
</div>
<Button
variant="ghost"
size="sm"
@click.stop="deletePayLink(link)"
class="text-destructive hover:text-destructive"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Selected Pay Link Details -->
<div v-if="selectedPayLink" class="border rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<h4 class="font-medium">{{ selectedPayLink.description }}</h4>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ selectedPayLink.min }}-{{ selectedPayLink.max }} sats</span>
</div>
</div>
<Separator />
<!-- QR Code Display -->
<div class="flex flex-col items-center space-y-4">
<div v-if="isLoadingQR" 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="qrCode" class="bg-white p-4 rounded-lg">
<img
:src="qrCode"
alt="LNURL QR Code"
class="w-48 h-48"
/>
</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>
<!-- LNURL -->
<div class="w-full space-y-2">
<Label>LNURL</Label>
<div class="flex gap-2">
<Input
:value="selectedPayLink.lnurl"
readonly
class="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
@click="copyToClipboard(selectedPayLink.lnurl || '', 'lnurl')"
>
<Check v-if="copiedField === 'lnurl'" class="h-4 w-4 text-green-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Lightning Address (if available) -->
<div v-if="selectedPayLink.lnaddress" class="w-full space-y-2">
<Label>Lightning Address</Label>
<div class="flex gap-2">
<Input
:value="selectedPayLink.lnaddress"
readonly
class="font-mono"
/>
<Button
variant="outline"
size="sm"
@click="copyToClipboard(selectedPayLink.lnaddress || '', 'lnaddress')"
>
<Check v-if="copiedField === 'lnaddress'" class="h-4 w-4 text-green-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<Button variant="outline" @click="closeDialog">
Close
</Button>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Send, AlertCircle, Loader2 } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
interface Props {
open: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Services
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()
}))
// Form setup
const form = useForm({
validationSchema: formSchema,
initialValues: {
destination: '',
amount: 100,
comment: ''
}
})
const { resetForm, values, meta } = form
const isFormValid = computed(() => meta.value.valid)
// State
const isSending = computed(() => walletService?.isSendingPayment?.value || false)
const error = computed(() => walletService?.error?.value)
// Methods
const onSubmit = form.handleSubmit(async (formValues) => {
try {
const success = await walletService.sendPayment({
destination: formValues.destination,
amount: formValues.amount,
comment: formValues.comment || undefined
})
if (success) {
toastService?.success('Payment sent successfully!')
closeDialog()
}
} catch (error) {
console.error('Failed to send payment:', error)
}
})
function closeDialog() {
emit('update:open', false)
resetForm()
}
// 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'
}
return ''
})
</script>
<template>
<Dialog :open="props.open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Send class="h-5 w-5" />
Send Bitcoin
</DialogTitle>
<DialogDescription>
Send Bitcoin via Lightning Network
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="destination">
<FormItem>
<FormLabel>Destination *</FormLabel>
<FormControl>
<Textarea
placeholder="Lightning invoice, LNURL, or Lightning address (user@domain.com)"
v-bind="componentField"
class="min-h-[80px] font-mono text-xs"
/>
</FormControl>
<FormDescription v-if="destinationType">
Detected: {{ destinationType }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount (sats) *</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="100"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Amount to send in satoshis</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="comment">
<FormItem>
<FormLabel>Comment (Optional)</FormLabel>
<FormControl>
<Input
placeholder="Optional message with payment"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Add a note to your payment (if supported)</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Alert v-if="error" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>
{{ error }}
</AlertDescription>
</Alert>
<div class="flex gap-2 pt-4">
<Button
type="submit"
:disabled="!isFormValid || isSending"
class="flex-1"
>
<Loader2 v-if="isSending" class="w-4 h-4 mr-2 animate-spin" />
<Send v-else class="w-4 h-4 mr-2" />
{{ isSending ? 'Sending...' : 'Send Payment' }}
</Button>
<Button type="button" variant="outline" @click="closeDialog" :disabled="isSending">
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
// This component is exported but not currently used
// It could be used as a standalone transaction list component if needed
</script>
<template>
<div>
<!-- Placeholder component for future use -->
</div>
</template>

View file

@ -0,0 +1,4 @@
export { default as WalletPage } from '../views/WalletPage.vue'
export { default as ReceiveDialog } from './ReceiveDialog.vue'
export { default as SendDialog } from './SendDialog.vue'
export { default as WalletTransactions } from './WalletTransactions.vue'

View file

@ -0,0 +1,51 @@
import type { App } from 'vue'
import type { ModulePlugin } from '@/core/plugin-manager'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import WalletService from './services/WalletService'
import { WalletPage, SendDialog, ReceiveDialog, WalletTransactions } from './components'
export const walletModule: ModulePlugin = {
name: 'wallet',
version: '1.0.0',
dependencies: ['base'],
async install(app: App) {
// Register wallet service in DI container
const walletService = new WalletService()
container.provide(SERVICE_TOKENS.WALLET_SERVICE, walletService)
// Register components globally
app.component('WalletPage', WalletPage)
app.component('SendDialog', SendDialog)
app.component('ReceiveDialog', ReceiveDialog)
app.component('WalletTransactions', WalletTransactions)
await walletService.initialize()
},
routes: [
{
path: '/wallet',
name: 'Wallet',
component: () => import('./views/WalletPage.vue'),
meta: { requiresAuth: true }
}
],
components: {
WalletPage,
SendDialog,
ReceiveDialog,
WalletTransactions
},
userMenuItems: [
{
name: 'Wallet',
href: '/wallet',
icon: 'Wallet'
}
]
}
export default walletModule

View file

@ -0,0 +1,327 @@
import { ref, computed } from 'vue'
import { BaseService } from '@/core/services/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
export interface PayLink {
id: string
wallet: string
description: string
min: number
max: number
comment_chars: number
username?: string
lnurl?: string
lnaddress?: string
}
export interface SendPaymentRequest {
amount: number
destination: string // Can be invoice, lnurl, or lightning address
comment?: string
}
export interface PaymentTransaction {
id: string
amount: number
description: string
timestamp: Date
type: 'sent' | 'received'
status: 'pending' | 'confirmed' | 'failed'
fee?: number
}
export default class WalletService extends BaseService {
private paymentService: any = null
// Reactive state
private _payLinks = ref<PayLink[]>([])
private _transactions = ref<PaymentTransaction[]>([])
private _isCreatingPayLink = ref(false)
private _isSendingPayment = ref(false)
private _error = ref<string | null>(null)
// Public reactive getters
readonly payLinks = computed(() => this._payLinks.value)
readonly transactions = computed(() => this._transactions.value)
readonly isCreatingPayLink = computed(() => this._isCreatingPayLink.value)
readonly isSendingPayment = computed(() => this._isSendingPayment.value)
readonly error = computed(() => this._error.value)
constructor() {
super('WalletService')
}
async initialize(): Promise<void> {
try {
this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
if (!this.paymentService) {
throw new Error('Payment service not available')
}
// Load initial data
await this.loadPayLinks()
await this.loadTransactions()
this.logger.info('WalletService initialized successfully')
} catch (error) {
this.logger.error('Failed to initialize WalletService:', error)
this._error.value = error instanceof Error ? error.message : 'Initialization failed'
}
}
/**
* Create a new LNURL pay link for receiving payments
*/
async createReceiveAddress(params: {
description: string
minAmount: number
maxAmount: number
username?: string
allowComments?: boolean
}): Promise<PayLink | null> {
this._isCreatingPayLink.value = true
this._error.value = null
try {
const wallet = this.paymentService?.getPreferredWallet()
if (!wallet) {
throw new Error('No wallet available')
}
const adminKey = this.paymentService?.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No admin key available')
}
// Create pay link via LNbits LNURLP extension API
const response = await fetch(`${this.paymentService.config.baseUrl}/lnurlp/api/v1/links`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': adminKey
},
body: JSON.stringify({
description: params.description,
wallet: wallet.id,
min: params.minAmount,
max: params.maxAmount,
comment_chars: params.allowComments ? 200 : 0,
username: params.username || null,
disposable: false // Reusable pay link
})
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create pay link')
}
const payLink: PayLink = await response.json()
// Generate the LNURL and Lightning Address
const baseUrl = this.paymentService.config.baseUrl
payLink.lnurl = `${baseUrl}/lnurlp/${payLink.id}`
if (payLink.username) {
// Extract domain from base URL
const domain = new URL(baseUrl).hostname
payLink.lnaddress = `${payLink.username}@${domain}`
}
this._payLinks.value.unshift(payLink)
this.logger.info('Created new pay link:', payLink.id)
return payLink
} catch (error) {
this.logger.error('Failed to create receive address:', error)
this._error.value = error instanceof Error ? error.message : 'Failed to create receive address'
return null
} finally {
this._isCreatingPayLink.value = false
}
}
/**
* Send a Lightning payment
*/
async sendPayment(request: SendPaymentRequest): Promise<boolean> {
this._isSendingPayment.value = true
this._error.value = null
try {
const adminKey = this.paymentService?.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No admin key available')
}
let endpoint = ''
let body: any = {}
// Determine payment type and prepare request
if (request.destination.startsWith('ln')) {
// Lightning invoice
endpoint = `${this.paymentService.config.baseUrl}/api/v1/payments`
body = {
out: true,
bolt11: request.destination
}
} else if (request.destination.includes('@') || request.destination.toLowerCase().startsWith('lnurl')) {
// Lightning address or LNURL
endpoint = `${this.paymentService.config.baseUrl}/api/v1/payments/lnurl`
body = {
lnurl: request.destination.includes('@')
? `https://${request.destination.split('@')[1]}/.well-known/lnurlp/${request.destination.split('@')[0]}`
: request.destination,
amount: request.amount * 1000, // Convert to millisats
comment: request.comment || ''
}
} else {
throw new Error('Invalid payment destination format')
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': adminKey
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Payment failed')
}
const payment = await response.json()
this.logger.info('Payment sent successfully:', payment.payment_hash)
// Refresh transactions
await this.loadTransactions()
return true
} catch (error) {
this.logger.error('Failed to send payment:', error)
this._error.value = error instanceof Error ? error.message : 'Payment failed'
return false
} finally {
this._isSendingPayment.value = false
}
}
/**
* Load existing pay links
*/
private async loadPayLinks(): Promise<void> {
try {
const adminKey = this.paymentService?.getPreferredWalletAdminKey()
if (!adminKey) return
const response = await fetch(`${this.paymentService.config.baseUrl}/lnurlp/api/v1/links`, {
headers: {
'X-Api-Key': adminKey
}
})
if (response.ok) {
const links = await response.json()
const baseUrl = this.paymentService.config.baseUrl
const domain = new URL(baseUrl).hostname
// Add LNURL and Lightning Address to each link
this._payLinks.value = links.map((link: PayLink) => ({
...link,
lnurl: `${baseUrl}/lnurlp/${link.id}`,
lnaddress: link.username ? `${link.username}@${domain}` : undefined
}))
this.logger.info(`Loaded ${links.length} pay links`)
}
} catch (error) {
this.logger.error('Failed to load pay links:', error)
}
}
/**
* Load payment transactions
*/
private async loadTransactions(): Promise<void> {
try {
const invoiceKey = this.paymentService?.getPreferredWalletInvoiceKey()
if (!invoiceKey) return
const response = await fetch(`${this.paymentService.config.baseUrl}/api/v1/payments`, {
headers: {
'X-Api-Key': invoiceKey
}
})
if (response.ok) {
const payments = await response.json()
// Transform to our transaction format
this._transactions.value = payments.map((payment: any) => ({
id: payment.payment_hash,
amount: Math.abs(payment.amount),
description: payment.memo || payment.description || 'No description',
timestamp: new Date(payment.time * 1000),
type: payment.amount > 0 ? 'received' : 'sent',
status: payment.pending ? 'pending' : 'confirmed',
fee: payment.fee
})).sort((a: PaymentTransaction, b: PaymentTransaction) =>
b.timestamp.getTime() - a.timestamp.getTime()
)
this.logger.info(`Loaded ${payments.length} transactions`)
}
} catch (error) {
this.logger.error('Failed to load transactions:', error)
}
}
/**
* Delete a pay link
*/
async deletePayLink(linkId: string): Promise<boolean> {
try {
const adminKey = this.paymentService?.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No admin key available')
}
const response = await fetch(`${this.paymentService.config.baseUrl}/lnurlp/api/v1/links/${linkId}`, {
method: 'DELETE',
headers: {
'X-Api-Key': adminKey
}
})
if (!response.ok) {
throw new Error('Failed to delete pay link')
}
// Remove from local state
this._payLinks.value = this._payLinks.value.filter(link => link.id !== linkId)
this.logger.info('Deleted pay link:', linkId)
return true
} catch (error) {
this.logger.error('Failed to delete pay link:', error)
this._error.value = error instanceof Error ? error.message : 'Failed to delete pay link'
return false
}
}
/**
* Refresh all data
*/
async refresh(): Promise<void> {
await Promise.all([
this.loadPayLinks(),
this.loadTransactions()
])
}
}

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>