From f7450627bc2cd5efbbbe7aca359fadeb8281bad9 Mon Sep 17 00:00:00 2001 From: padreug Date: Fri, 1 Aug 2025 21:33:11 +0200 Subject: [PATCH] refactor: Revamp PurchaseTicketDialog and introduce useTicketPurchase composable - Update PurchaseTicketDialog.vue to integrate authentication checks and enhance ticket purchasing flow with wallet support. - Implement useTicketPurchase composable for managing ticket purchase logic, including payment handling and QR code generation. - Improve user experience by displaying user information and wallet status during the ticket purchase process. - Refactor API interactions in events.ts to streamline ticket purchasing and payment status checks. --- .../events/PurchaseTicketDialog.vue | 267 +++++++++++------- src/composables/useTicketPurchase.ts | 220 +++++++++++++++ src/lib/api/events.ts | 109 ++++++- src/lib/api/lnbits.ts | 34 +++ src/pages/events.vue | 38 ++- 5 files changed, 555 insertions(+), 113 deletions(-) create mode 100644 src/composables/useTicketPurchase.ts diff --git a/src/components/events/PurchaseTicketDialog.vue b/src/components/events/PurchaseTicketDialog.vue index 08767e3..e5b2cf4 100644 --- a/src/components/events/PurchaseTicketDialog.vue +++ b/src/components/events/PurchaseTicketDialog.vue @@ -1,12 +1,12 @@ \ No newline at end of file diff --git a/src/composables/useTicketPurchase.ts b/src/composables/useTicketPurchase.ts new file mode 100644 index 0000000..76cdf97 --- /dev/null +++ b/src/composables/useTicketPurchase.ts @@ -0,0 +1,220 @@ +import { ref, computed } from 'vue' +import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events' +import { useAuth } from './useAuth' +import { toast } from 'vue-sonner' + +interface Wallet { + id: string + user: string + name: string + adminkey: string + inkey: string + deleted: boolean + created_at: string + updated_at: string + currency?: string + balance_msat: number + extra?: { + icon: string + color: string + pinned: boolean + } +} + +export function useTicketPurchase() { + const { isAuthenticated, currentUser } = useAuth() + const isLoading = ref(false) + const error = ref(null) + const paymentHash = ref('') + const paymentRequest = ref('') + const qrCode = ref('') + const isPaymentPending = ref(false) + const paymentCheckInterval = ref(null) + const isPayingWithWallet = ref(false) + + const canPurchase = computed(() => { + return isAuthenticated.value && !isLoading.value + }) + + const userDisplay = computed(() => { + if (!currentUser.value) return null + + return { + name: currentUser.value.username || currentUser.value.email || 'Anonymous', + id: currentUser.value.id, + shortId: currentUser.value.id.slice(0, 8) + '...' + currentUser.value.id.slice(-8) + } + }) + + const userWallets = computed(() => { + if (!currentUser.value) return [] as Wallet[] + return currentUser.value.wallets || [] + }) + + const hasWalletWithBalance = computed(() => { + return userWallets.value.some((wallet: Wallet) => wallet.balance_msat > 0) + }) + + async function generateQRCode(bolt11: string) { + try { + const QRCode = await import('qrcode') + qrCode.value = await QRCode.toDataURL(`lightning:${bolt11}`) + } catch (err) { + console.error('Failed to generate QR code:', err) + } + } + + async function payWithWallet(paymentRequest: string) { + if (!currentUser.value || !userWallets.value.length) { + throw new Error('No wallet available for payment') + } + + // Find the first wallet with sufficient balance + const wallet = userWallets.value.find((w: Wallet) => w.balance_msat > 0) + if (!wallet) { + throw new Error('No wallet with sufficient balance found') + } + + try { + isPayingWithWallet.value = true + const result = await payInvoiceWithWallet(paymentRequest, wallet.id, wallet.adminkey) + + toast.success(`Payment successful! Fee: ${result.fee_msat} msat`) + return result + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to pay with wallet' + toast.error(message) + throw err + } finally { + isPayingWithWallet.value = false + } + } + + async function purchaseTicketForEvent(eventId: string) { + if (!isAuthenticated.value) { + error.value = 'Please log in to purchase tickets' + toast.error('Please log in to purchase tickets') + return + } + + try { + isLoading.value = true + error.value = '' + + // Step 1: Get the invoice + const result = await purchaseTicket(eventId) + + paymentHash.value = result.payment_hash + paymentRequest.value = result.payment_request + await generateQRCode(result.payment_request) + + // Step 2: Try to pay with wallet if user has balance + if (hasWalletWithBalance.value) { + try { + await payWithWallet(result.payment_request) + // Payment successful, start monitoring for ticket confirmation + startPaymentStatusCheck(eventId, result.payment_hash) + return + } catch (walletPaymentError) { + console.log('Wallet payment failed, showing QR code for manual payment:', walletPaymentError) + // Fall back to QR code payment + startPaymentStatusCheck(eventId, result.payment_hash) + } + } else { + // No wallet balance, show QR code for manual payment + startPaymentStatusCheck(eventId, result.payment_hash) + } + + toast.success('Payment request generated successfully') + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to generate payment request' + error.value = message + toast.error(message) + throw err + } finally { + isLoading.value = false + } + } + + function startPaymentStatusCheck(eventId: string, paymentHash: string) { + isPaymentPending.value = true + + // Clear any existing interval + if (paymentCheckInterval.value) { + clearInterval(paymentCheckInterval.value) + } + + // Check payment status every 2 seconds + paymentCheckInterval.value = window.setInterval(async () => { + try { + const status = await checkPaymentStatus(eventId, paymentHash) + + if (status.paid) { + // Payment successful + clearInterval(paymentCheckInterval.value!) + paymentCheckInterval.value = null + isPaymentPending.value = false + + toast.success('Payment successful! Your ticket has been purchased.') + + // Reset payment state + resetPaymentState() + + return { success: true, ticketId: status.ticket_id } + } + } catch (err) { + console.error('Error checking payment status:', err) + // Don't show error to user for status checks, just log it + } + }, 2000) + } + + function stopPaymentStatusCheck() { + if (paymentCheckInterval.value) { + clearInterval(paymentCheckInterval.value) + paymentCheckInterval.value = null + } + isPaymentPending.value = false + } + + function resetPaymentState() { + paymentHash.value = '' + paymentRequest.value = '' + qrCode.value = '' + error.value = '' + stopPaymentStatusCheck() + } + + function handleOpenLightningWallet() { + if (paymentRequest.value) { + window.location.href = `lightning:${paymentRequest.value}` + } + } + + // Cleanup on unmount + function cleanup() { + stopPaymentStatusCheck() + } + + return { + // State + isLoading, + error, + paymentHash, + paymentRequest, + qrCode, + isPaymentPending, + isPayingWithWallet, + canPurchase, + userDisplay, + userWallets, + hasWalletWithBalance, + + // Actions + purchaseTicketForEvent, + payWithWallet, + handleOpenLightningWallet, + resetPaymentState, + cleanup, + } +} \ No newline at end of file diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index eb1b4b5..68eb6cf 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -1,9 +1,15 @@ import type { Event, EventsApiError } from '../types/event' import { config } from '@/lib/config' +import { lnbitsAPI } from './lnbits' const API_BASE_URL = config.api.baseUrl || 'http://lnbits' const API_KEY = config.api.key +// Generic error type for API responses +interface ApiError { + detail: string | Array<{ loc: [string, number]; msg: string; type: string }> +} + export async function fetchEvents(allWallets = true): Promise { try { const response = await fetch( @@ -17,8 +23,11 @@ export async function fetchEvents(allWallets = true): Promise { ) if (!response.ok) { - const error: EventsApiError = await response.json() - throw new Error(error.detail[0]?.msg || 'Failed to fetch events') + const error: ApiError = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to fetch events' + throw new Error(errorMessage) } return await response.json() as Event[] @@ -26,4 +35,100 @@ export async function fetchEvents(allWallets = true): Promise { console.error('Error fetching events:', error) throw error } +} + +export async function purchaseTicket(eventId: string): Promise<{ payment_hash: string; payment_request: string }> { + try { + // Get current user to ensure authentication + const user = await lnbitsAPI.getCurrentUser() + if (!user) { + throw new Error('User not authenticated') + } + + const response = await fetch( + `${API_BASE_URL}/events/api/v1/tickets/${eventId}/user/${user.id}`, + { + method: 'GET', + headers: { + 'accept': 'application/json', + 'X-API-KEY': API_KEY, + 'Authorization': `Bearer ${lnbitsAPI.getAccessToken()}`, + }, + } + ) + + if (!response.ok) { + const error: ApiError = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to purchase ticket' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error purchasing ticket:', error) + throw error + } +} + +export async function payInvoiceWithWallet(paymentRequest: string, walletId: string, adminKey: string): Promise<{ payment_hash: string; fee_msat: number; preimage: string }> { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/payments`, + { + method: 'POST', + headers: { + 'accept': 'application/json', + 'Content-Type': 'application/json', + 'X-API-KEY': adminKey, + }, + body: JSON.stringify({ + out: true, + bolt11: paymentRequest, + }), + } + ) + + if (!response.ok) { + const error: ApiError = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to pay invoice' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error paying invoice:', error) + throw error + } +} + +export async function checkPaymentStatus(eventId: string, paymentHash: string): Promise<{ paid: boolean; ticket_id?: string }> { + try { + const response = await fetch( + `${API_BASE_URL}/events/api/v1/tickets/${eventId}/${paymentHash}`, + { + method: 'POST', + headers: { + 'accept': 'application/json', + 'X-API-KEY': API_KEY, + }, + } + ) + + if (!response.ok) { + const error: ApiError = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to check payment status' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error checking payment status:', error) + throw error + } } \ No newline at end of file diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index e13a64c..91b9592 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -17,13 +17,47 @@ interface AuthResponse { email?: string } +interface Wallet { + id: string + user: string + name: string + adminkey: string + inkey: string + deleted: boolean + created_at: string + updated_at: string + currency?: string + balance_msat: number + extra?: { + icon: string + color: string + pinned: boolean + } +} + interface User { id: string username?: string email?: string pubkey?: string + external_id?: string + extensions: string[] + wallets: Wallet[] + admin: boolean + super_user: boolean + fiat_providers: string[] + has_password: boolean created_at: string updated_at: string + extra?: { + email_verified?: boolean + first_name?: string + last_name?: string + display_name?: string + picture?: string + provider?: string + visible_wallet_count?: number + } } import { getApiUrl, getAuthToken, setAuthToken, removeAuthToken } from '@/lib/config/lnbits' diff --git a/src/pages/events.vue b/src/pages/events.vue index 845571c..e3f1182 100644 --- a/src/pages/events.vue +++ b/src/pages/events.vue @@ -2,15 +2,18 @@