Merge branch 'send-receive-satoshis'
This commit is contained in:
commit
453bb53282
12 changed files with 1554 additions and 8 deletions
|
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
11
src/app.ts
11
src/app.ts
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity, ShoppingCart, Store, Calendar, ShoppingBag } from 'lucide-vue-next'
|
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity, ShoppingCart, Store, Calendar, ShoppingBag, ChevronRight } from 'lucide-vue-next'
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||||
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
|
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -265,10 +265,16 @@ const handleLogout = async () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Wallet Balance -->
|
<!-- Mobile Wallet Balance -->
|
||||||
<div class="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded-lg">
|
<button
|
||||||
<Wallet class="h-4 w-4 text-muted-foreground" />
|
@click="() => { router.push('/wallet'); isOpen = false }"
|
||||||
<CurrencyDisplay :balance-msat="totalBalance" />
|
class="flex items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-lg hover:bg-muted/70 transition-colors w-full text-left border border-transparent hover:border-muted-foreground/20"
|
||||||
</div>
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Wallet class="h-4 w-4 text-muted-foreground" />
|
||||||
|
<CurrencyDisplay :balance-msat="totalBalance" />
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Button variant="ghost" size="sm" @click="openProfileDialog" class="w-full justify-start gap-2">
|
<Button variant="ghost" size="sm" @click="openProfileDialog" class="w-full justify-start gap-2">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
430
src/modules/wallet/components/ReceiveDialog.vue
Normal file
430
src/modules/wallet/components/ReceiveDialog.vue
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
<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 { nip19 } from 'nostr-tools'
|
||||||
|
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, 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 generateQRCode(data: string) {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
isLoadingQR.value = true
|
||||||
|
try {
|
||||||
|
// Encode LNURL with proper bech32 format and lightning: prefix
|
||||||
|
const encodedLNURL = encodeLNURL(data)
|
||||||
|
// Use the existing PaymentService QR code generation
|
||||||
|
qrCode.value = await paymentService?.generateQRCode(encodedLNURL)
|
||||||
|
} 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dialog open/close state changes
|
||||||
|
function onOpenChange(open: boolean) {
|
||||||
|
if (open && walletService) {
|
||||||
|
walletService.refresh()
|
||||||
|
} else if (!open) {
|
||||||
|
// Dialog is being closed (including X button) - just emit the update
|
||||||
|
emit('update:open', false)
|
||||||
|
// Clean up state
|
||||||
|
resetForm()
|
||||||
|
selectedPayLink.value = null
|
||||||
|
activeTab.value = 'create'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Encoded LNURL (for QR) -->
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<Label>LNURL (Encoded)</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:value="selectedPayLink.lnurl ? encodeLNURL(selectedPayLink.lnurl) : ''"
|
||||||
|
readonly
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="copyToClipboard(selectedPayLink.lnurl ? encodeLNURL(selectedPayLink.lnurl) : '', 'encoded-lnurl')"
|
||||||
|
>
|
||||||
|
<Check v-if="copiedField === 'encoded-lnurl'" class="h-4 w-4 text-green-600" />
|
||||||
|
<Copy v-else class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw LNURL -->
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<Label>LNURL (Raw)</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>
|
||||||
173
src/modules/wallet/components/SendDialog.vue
Normal file
173
src/modules/wallet/components/SendDialog.vue
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { 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 { 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>
|
||||||
|
|
||||||
|
<div v-if="error" class="flex items-start gap-2 p-3 bg-destructive/15 text-destructive rounded-lg">
|
||||||
|
<AlertCircle class="h-4 w-4 mt-0.5" />
|
||||||
|
<span class="text-sm">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
10
src/modules/wallet/components/WalletTransactions.vue
Normal file
10
src/modules/wallet/components/WalletTransactions.vue
Normal 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>
|
||||||
4
src/modules/wallet/components/index.ts
Normal file
4
src/modules/wallet/components/index.ts
Normal 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'
|
||||||
43
src/modules/wallet/index.ts
Normal file
43
src/modules/wallet/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { App } from 'vue'
|
||||||
|
import type { ModulePlugin } from '@/core/types'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default walletModule
|
||||||
353
src/modules/wallet/services/WalletService.ts
Normal file
353
src/modules/wallet/services/WalletService.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
|
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
|
||||||
|
tag?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class WalletService extends BaseService {
|
||||||
|
private paymentService: any = null
|
||||||
|
|
||||||
|
// Required metadata for BaseService
|
||||||
|
protected readonly metadata = {
|
||||||
|
name: 'WalletService',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: [] // No specific dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
protected async onInitialize(): 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()
|
||||||
|
|
||||||
|
console.log('WalletService initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize WalletService:', error)
|
||||||
|
this._error.value = error instanceof Error ? error.message : 'Initialization failed'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(`${config.api.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 = config.api.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)
|
||||||
|
console.log('Created new pay link:', payLink.id)
|
||||||
|
|
||||||
|
return payLink
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.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 = `${config.api.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 = `${config.api.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()
|
||||||
|
console.log('Payment sent successfully:', payment.payment_hash)
|
||||||
|
|
||||||
|
// Refresh transactions
|
||||||
|
await this.loadTransactions()
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.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(`${config.api.baseUrl}/lnurlp/api/v1/links`, {
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': adminKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const links = await response.json()
|
||||||
|
const baseUrl = config.api.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
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log(`Loaded ${links.length} pay links`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.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(`${config.api.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) => {
|
||||||
|
let timestamp = new Date()
|
||||||
|
|
||||||
|
if (payment.time) {
|
||||||
|
// Check if it's an ISO string or Unix timestamp
|
||||||
|
if (typeof payment.time === 'string' && payment.time.includes('T')) {
|
||||||
|
// ISO string format (e.g., "2025-09-14T16:49:40.378877+00:00")
|
||||||
|
timestamp = new Date(payment.time)
|
||||||
|
} else if (typeof payment.time === 'number' || !isNaN(Number(payment.time))) {
|
||||||
|
// Unix timestamp (seconds) - multiply by 1000 for milliseconds
|
||||||
|
timestamp = new Date(Number(payment.time) * 1000)
|
||||||
|
} else {
|
||||||
|
// Try to parse as-is
|
||||||
|
timestamp = new Date(payment.time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payment.payment_hash,
|
||||||
|
amount: Math.abs(payment.amount) / 1000,
|
||||||
|
description: payment.memo || payment.description || 'No description',
|
||||||
|
timestamp: timestamp,
|
||||||
|
type: payment.amount > 0 ? 'received' : 'sent',
|
||||||
|
status: payment.pending ? 'pending' : 'confirmed',
|
||||||
|
fee: payment.fee ? payment.fee / 1000 : undefined,
|
||||||
|
tag: payment.tag || (payment.extra && payment.extra.tag) || null
|
||||||
|
}
|
||||||
|
}).sort((a: PaymentTransaction, b: PaymentTransaction) =>
|
||||||
|
b.timestamp.getTime() - a.timestamp.getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Loaded ${payments.length} transactions`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.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(`${config.api.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)
|
||||||
|
console.log('Deleted pay link:', linkId)
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.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()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
495
src/modules/wallet/views/WalletPage.vue
Normal file
495
src/modules/wallet/views/WalletPage.vue
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
<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 } 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
|
||||||
|
|
||||||
|
// State
|
||||||
|
const showReceiveDialog = ref(false)
|
||||||
|
const showSendDialog = ref(false)
|
||||||
|
const selectedTab = ref('transactions')
|
||||||
|
const defaultQrCode = ref<string | null>(null)
|
||||||
|
const isGeneratingQR = ref(false)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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="bg-white p-4 rounded-lg">
|
||||||
|
<img
|
||||||
|
:src="defaultQrCode"
|
||||||
|
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>
|
||||||
|
</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="font-mono text-sm bg-muted px-3 py-2 rounded">
|
||||||
|
{{ firstPayLink.lnaddress }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="showReceiveDialog = true"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<QrCode class="h-4 w-4 mr-2" />
|
||||||
|
Manage Addresses
|
||||||
|
</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-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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue