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.
This commit is contained in:
parent
ed51c95799
commit
f7450627bc
5 changed files with 555 additions and 113 deletions
|
|
@ -1,12 +1,12 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onUnmounted } from 'vue'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { useTicketPurchase } from '@/composables/useTicketPurchase'
|
||||||
import QRCode from 'qrcode'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { config } from '@/lib/config'
|
import { User, Wallet, CreditCard, Zap } from 'lucide-vue-next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -25,138 +25,193 @@ interface Emits {
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const name = ref('')
|
const { isAuthenticated, userDisplay } = useAuth()
|
||||||
const email = ref('')
|
const {
|
||||||
const isLoading = ref(false)
|
isLoading,
|
||||||
const paymentHash = ref('')
|
error,
|
||||||
const paymentRequest = ref('')
|
paymentHash,
|
||||||
const qrCode = ref('')
|
paymentRequest,
|
||||||
const error = ref('')
|
qrCode,
|
||||||
|
isPaymentPending,
|
||||||
|
isPayingWithWallet,
|
||||||
|
canPurchase,
|
||||||
|
userWallets,
|
||||||
|
hasWalletWithBalance,
|
||||||
|
purchaseTicketForEvent,
|
||||||
|
handleOpenLightningWallet,
|
||||||
|
resetPaymentState,
|
||||||
|
cleanup
|
||||||
|
} = useTicketPurchase()
|
||||||
|
|
||||||
async function generateQRCode(bolt11: string) {
|
async function handlePurchase() {
|
||||||
try {
|
if (!canPurchase.value) return
|
||||||
qrCode.value = await QRCode.toDataURL(`lightning:${bolt11}`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to generate QR code:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!name.value || !email.value) {
|
|
||||||
error.value = 'Please fill out all fields'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
const apiUrl = `${config.api.baseUrl}/events/api/v1/tickets/${props.event.id}/${encodeURIComponent(name.value)}/${encodeURIComponent(email.value)}`
|
|
||||||
console.log('Calling API:', apiUrl)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
await purchaseTicketForEvent(props.event.id)
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-API-KEY': config.api.key
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Response status:', response.status)
|
|
||||||
const responseText = await response.text()
|
|
||||||
console.log('Response body:', responseText)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = 'Failed to generate payment request'
|
|
||||||
try {
|
|
||||||
const errorData = JSON.parse(responseText)
|
|
||||||
errorMessage = errorData.detail || errorMessage
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse error response:', e)
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(responseText)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse response as JSON:', e)
|
|
||||||
throw new Error('Invalid response format from server')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.payment_hash || !data.payment_request) {
|
|
||||||
console.error('Invalid response data:', data)
|
|
||||||
throw new Error('Invalid payment data received')
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentHash.value = data.payment_hash
|
|
||||||
paymentRequest.value = data.payment_request
|
|
||||||
await generateQRCode(data.payment_request)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error generating ticket:', err)
|
console.error('Error purchasing ticket:', err)
|
||||||
error.value = err instanceof Error ? err.message : 'An error occurred while processing your request'
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenLightningWallet() {
|
|
||||||
if (paymentRequest.value) {
|
|
||||||
window.location.href = `lightning:${paymentRequest.value}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
// Reset form state
|
resetPaymentState()
|
||||||
name.value = ''
|
|
||||||
email.value = ''
|
|
||||||
paymentHash.value = ''
|
|
||||||
paymentRequest.value = ''
|
|
||||||
qrCode.value = ''
|
|
||||||
error.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="isOpen" @update:open="handleClose">
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
<DialogContent class="sm:max-w-[425px]">
|
<DialogContent class="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Purchase Ticket</DialogTitle>
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<CreditCard class="w-5 h-5" />
|
||||||
|
Purchase Ticket
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Purchase a ticket for {{ event.name }} for {{ event.price_per_ticket }} {{ event.currency }}
|
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ event.price_per_ticket }} {{ event.currency }}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div v-if="!paymentHash" class="grid gap-4 py-4">
|
<!-- Authentication Check -->
|
||||||
<div class="grid gap-2">
|
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
|
||||||
<Label for="name">Name</Label>
|
<div class="flex justify-center">
|
||||||
<Input id="name" v-model="name" placeholder="Enter your name" :disabled="isLoading" />
|
<User class="w-12 h-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="space-y-2">
|
||||||
<Label for="email">Email</Label>
|
<h3 class="text-lg font-semibold">Login Required</h3>
|
||||||
<Input id="email" v-model="email" type="email" placeholder="Enter your email" :disabled="isLoading" />
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Please log in to your account to purchase tickets using your wallet.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="text-sm text-destructive">
|
<Button @click="handleClose" variant="outline">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info and Purchase -->
|
||||||
|
<div v-else-if="!paymentHash" class="py-4 space-y-4">
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">Purchasing as:</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">Name:</span>
|
||||||
|
<span class="text-sm font-medium">{{ userDisplay?.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">User ID:</span>
|
||||||
|
<Badge variant="secondary" class="text-xs font-mono">{{ userDisplay?.shortId }}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallet Information -->
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">Wallet Status:</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="userWallets.length === 0" class="text-sm text-muted-foreground">
|
||||||
|
No wallets found
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div v-for="wallet in userWallets" :key="wallet.id" class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium">{{ wallet.name }}</span>
|
||||||
|
<Badge v-if="wallet.balance_msat > 0" variant="default" class="text-xs">
|
||||||
|
{{ (wallet.balance_msat / 1000).toFixed(0) }} sats
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
|
||||||
|
<Zap class="w-4 h-4" />
|
||||||
|
<span>Auto-payment available</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Manual payment required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">Payment Details:</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">Event:</span>
|
||||||
|
<span class="text-sm font-medium">{{ event.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">Price:</span>
|
||||||
|
<span class="text-sm font-medium">{{ event.price_per_ticket }} {{ event.currency }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="handlePurchase"
|
||||||
|
:disabled="isLoading || !canPurchase"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
||||||
|
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
||||||
|
<Zap class="w-4 h-4" />
|
||||||
|
Pay with Wallet
|
||||||
|
</span>
|
||||||
|
<span v-else>Generate Payment Request</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment QR Code and Status -->
|
||||||
<div v-else class="py-4 flex flex-col items-center gap-4">
|
<div v-else class="py-4 flex flex-col items-center gap-4">
|
||||||
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64" />
|
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<p class="text-sm text-muted-foreground">Scan with your Lightning wallet to pay</p>
|
<h3 class="text-lg font-semibold">Payment Required</h3>
|
||||||
<Button variant="outline" @click="handleOpenLightningWallet">
|
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
|
||||||
|
Processing payment with your wallet...
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
|
Scan the QR code with your Lightning wallet to complete the payment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isPayingWithWallet" class="bg-muted/50 rounded-lg p-4">
|
||||||
|
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 w-full">
|
||||||
|
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
|
||||||
|
<Wallet class="w-4 h-4 mr-2" />
|
||||||
Open in Lightning Wallet
|
Open in Lightning Wallet
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!paymentHash" class="flex justify-end">
|
<div v-if="isPaymentPending" class="text-center space-y-2">
|
||||||
<Button type="submit" :disabled="isLoading" @click="handleSubmit">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||||
Continue to Payment
|
<span class="text-sm text-muted-foreground">
|
||||||
</Button>
|
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Payment will be confirmed automatically once received
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
220
src/composables/useTicketPurchase.ts
Normal file
220
src/composables/useTicketPurchase.ts
Normal file
|
|
@ -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<string | null>(null)
|
||||||
|
const paymentHash = ref('')
|
||||||
|
const paymentRequest = ref('')
|
||||||
|
const qrCode = ref('')
|
||||||
|
const isPaymentPending = ref(false)
|
||||||
|
const paymentCheckInterval = ref<number | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import type { Event, EventsApiError } from '../types/event'
|
import type { Event, EventsApiError } from '../types/event'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
import { lnbitsAPI } from './lnbits'
|
||||||
|
|
||||||
const API_BASE_URL = config.api.baseUrl || 'http://lnbits'
|
const API_BASE_URL = config.api.baseUrl || 'http://lnbits'
|
||||||
const API_KEY = config.api.key
|
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<Event[]> {
|
export async function fetchEvents(allWallets = true): Promise<Event[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|
@ -17,8 +23,11 @@ export async function fetchEvents(allWallets = true): Promise<Event[]> {
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error: EventsApiError = await response.json()
|
const error: ApiError = await response.json()
|
||||||
throw new Error(error.detail[0]?.msg || 'Failed to fetch events')
|
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[]
|
return await response.json() as Event[]
|
||||||
|
|
@ -27,3 +36,99 @@ export async function fetchEvents(allWallets = true): Promise<Event[]> {
|
||||||
throw 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,13 +17,47 @@ interface AuthResponse {
|
||||||
email?: string
|
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 {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
|
external_id?: string
|
||||||
|
extensions: string[]
|
||||||
|
wallets: Wallet[]
|
||||||
|
admin: boolean
|
||||||
|
super_user: boolean
|
||||||
|
fiat_providers: string[]
|
||||||
|
has_password: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_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'
|
import { getApiUrl, getAuthToken, setAuthToken, removeAuthToken } from '@/lib/config/lnbits'
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useEvents } from '@/composables/useEvents'
|
import { useEvents } from '@/composables/useEvents'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import PurchaseTicketDialog from '@/components/events/PurchaseTicketDialog.vue'
|
import PurchaseTicketDialog from '@/components/events/PurchaseTicketDialog.vue'
|
||||||
import { RefreshCw } from 'lucide-vue-next'
|
import { RefreshCw, User, LogIn } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||||
|
const { isAuthenticated, userDisplay } = useAuth()
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false)
|
||||||
const selectedEvent = ref<{
|
const selectedEvent = ref<{
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -29,6 +32,12 @@ function handlePurchaseClick(event: {
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
}) {
|
}) {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
// Show login prompt or redirect to login
|
||||||
|
// You could emit an event to show login dialog here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
selectedEvent.value = event
|
selectedEvent.value = event
|
||||||
showPurchaseDialog.value = true
|
showPurchaseDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +46,18 @@ function handlePurchaseClick(event: {
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-8 px-4">
|
<div class="container mx-auto py-8 px-4">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold text-foreground">Events</h1>
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-3xl font-bold text-foreground">Events</h1>
|
||||||
|
<div v-if="isAuthenticated && userDisplay" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<span>Logged in as {{ userDisplay.name }}</span>
|
||||||
|
<Badge variant="outline" class="text-xs">{{ userDisplay.shortId }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<LogIn class="w-4 h-4" />
|
||||||
|
<span>Please log in to purchase tickets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
|
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
|
||||||
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': isLoading }" />
|
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': isLoading }" />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -84,9 +104,17 @@ function handlePurchaseClick(event: {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button class="w-full" variant="default" :disabled="event.amount_tickets <= event.sold"
|
<Button
|
||||||
@click="handlePurchaseClick(event)">
|
class="w-full"
|
||||||
Buy Ticket
|
variant="default"
|
||||||
|
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
|
||||||
|
@click="handlePurchaseClick(event)"
|
||||||
|
>
|
||||||
|
<span v-if="!isAuthenticated" class="flex items-center gap-2">
|
||||||
|
<LogIn class="w-4 h-4" />
|
||||||
|
Login to Purchase
|
||||||
|
</span>
|
||||||
|
<span v-else>Buy Ticket</span>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue