feat(activities): expose fiat checkout on event create + purchase
Both sides of the fiat-payment surface introduced by events v1.4.0: CreateEventDialog — organizer-side opt-in: - New "Accept fiat payments" switch (allow_fiat) + fiat currency picker (USD/EUR/GBP/CHF). Toggle is always shipped on create/edit so a true→false flip propagates correctly. - Hint copy notes that the host's LNbits admin needs a configured fiat provider (Stripe etc.) for the toggle to actually work at purchase time. PurchaseTicketDialog — buyer-side method selector: - Two-button selector (Lightning / Fiat) shown only when the event has `allow_fiat=true`. Hidden entirely for Lightning-only events. - Lightning path: unchanged (uses useTicketPurchase composable). - Fiat path: posts to the API with `payment_method=fiat`, then surfaces a "Open <provider> checkout" button that opens the returned fiat_payment_request URL in a new tab. Payment confirmation happens via webhook on the backend; ticket appears in My Tickets on next reload. EventsPage threads the new fiat fields through `selectedEvent` so the dialog sees them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73aee75b5b
commit
ec0dbf727b
3 changed files with 172 additions and 8 deletions
|
|
@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -120,6 +121,8 @@ const formSchema = toTypedSchema(
|
|||
event_end_time: z.string().optional().default(''),
|
||||
location: z.string().max(500).optional().default(''),
|
||||
currency: z.string().default("sat"),
|
||||
allow_fiat: z.boolean().default(false),
|
||||
fiat_currency: z.string().default("USD"),
|
||||
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||
price_per_ticket: z.number().min(0).default(0),
|
||||
})
|
||||
|
|
@ -150,6 +153,8 @@ const form = useForm({
|
|||
event_end_time: '',
|
||||
location: '',
|
||||
currency: 'sat',
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'USD',
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0,
|
||||
}
|
||||
|
|
@ -213,6 +218,8 @@ async function populateFromEvent(event: TicketedEvent) {
|
|||
event_end_time: end.time,
|
||||
location: event.location ?? '',
|
||||
currency: event.currency ?? 'sat',
|
||||
allow_fiat: event.allow_fiat ?? false,
|
||||
fiat_currency: event.fiat_currency ?? 'USD',
|
||||
amount_tickets: event.amount_tickets ?? 0,
|
||||
price_per_ticket: event.price_per_ticket ?? 0,
|
||||
})
|
||||
|
|
@ -318,6 +325,10 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
eventData.banner = null
|
||||
}
|
||||
if (formValues.currency) eventData.currency = formValues.currency
|
||||
// allow_fiat / fiat_currency: always send so the toggle reads
|
||||
// both directions on edit (a true→false flip must propagate).
|
||||
eventData.allow_fiat = formValues.allow_fiat
|
||||
if (formValues.fiat_currency) eventData.fiat_currency = formValues.fiat_currency
|
||||
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||
|
|
@ -591,6 +602,46 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Fiat checkout (organizer opts in). Backend requires the
|
||||
host's LNbits admin to have configured a fiat provider
|
||||
(Stripe etc.) under settings.fiat_providers; toggling
|
||||
this on without one will fail at purchase time. -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||
<FormField v-slot="{ value, handleChange }" name="allow_fiat">
|
||||
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel>Accept fiat payments</FormLabel>
|
||||
<FormDescription class="text-xs">
|
||||
Buyers can pay with the LNbits instance's configured fiat provider (e.g. Stripe).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="fiat_currency">
|
||||
<FormItem v-show="form.values.allow_fiat">
|
||||
<FormLabel>Fiat currency</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="USD" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="CHF">CHF</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { onUnmounted } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { PaymentMethod } from '../types/ticket'
|
||||
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink } from 'lucide-vue-next'
|
||||
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -14,6 +17,9 @@ interface Props {
|
|||
name: string
|
||||
price_per_ticket: number
|
||||
currency: string
|
||||
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
||||
allow_fiat?: boolean
|
||||
fiat_currency?: string
|
||||
}
|
||||
isOpen: boolean
|
||||
}
|
||||
|
|
@ -45,19 +51,74 @@ const {
|
|||
showTicketQR
|
||||
} = useTicketPurchase()
|
||||
|
||||
const paymentMethod = ref<PaymentMethod>('lightning')
|
||||
const fiatRedirectUrl = ref<string | null>(null)
|
||||
const fiatProviderLabel = ref<string | null>(null)
|
||||
const isFiatPending = ref(false)
|
||||
const fiatError = ref<string | null>(null)
|
||||
|
||||
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
|
||||
|
||||
async function handlePurchase() {
|
||||
if (!canPurchase.value) return
|
||||
fiatError.value = null
|
||||
|
||||
// Lightning path: existing composable handles QR + wallet auto-pay.
|
||||
if (paymentMethod.value === 'lightning') {
|
||||
try {
|
||||
await purchaseTicketForEvent(props.event.id)
|
||||
} catch (err) {
|
||||
console.error('Error purchasing ticket:', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
||||
// API directly, then redirect the buyer to the provider's checkout
|
||||
// URL. Payment confirmation happens via webhook on the backend and
|
||||
// shows up next time the buyer reloads MyTickets.
|
||||
try {
|
||||
isFiatPending.value = true
|
||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const currentUser = (lnbitsAPI?.currentUser?.value) || null
|
||||
const userId = currentUser?.id
|
||||
if (!userId) {
|
||||
fiatError.value = 'Missing user id'
|
||||
return
|
||||
}
|
||||
const invoice = await ticketApi.requestTicket(
|
||||
props.event.id,
|
||||
userId,
|
||||
accessToken,
|
||||
{ paymentMethod: 'fiat' },
|
||||
)
|
||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
||||
return
|
||||
}
|
||||
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
||||
fiatProviderLabel.value = invoice.fiatProvider ?? 'provider'
|
||||
} catch (err) {
|
||||
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
||||
} finally {
|
||||
isFiatPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openFiatCheckout() {
|
||||
if (!fiatRedirectUrl.value) return
|
||||
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:isOpen', false)
|
||||
resetPaymentState()
|
||||
paymentMethod.value = 'lightning'
|
||||
fiatRedirectUrl.value = null
|
||||
fiatProviderLabel.value = null
|
||||
fiatError.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -161,16 +222,64 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||
{{ error }}
|
||||
<!-- Payment method selector (only shown when fiat is enabled
|
||||
on the event). Hidden entirely for Lightning-only events
|
||||
to keep the dialog uncluttered. -->
|
||||
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="text-sm font-medium">Payment method</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:variant="paymentMethod === 'lightning' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="paymentMethod = 'lightning'"
|
||||
>
|
||||
<Zap class="w-4 h-4 mr-1.5" />
|
||||
Lightning
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="paymentMethod === 'fiat' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="paymentMethod = 'fiat'"
|
||||
>
|
||||
<CreditCard class="w-4 h-4 mr-1.5" />
|
||||
{{ event.fiat_currency ?? 'Fiat' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||
{{ error || fiatError }}
|
||||
</div>
|
||||
|
||||
<!-- Fiat checkout panel — shown after a successful fiat
|
||||
POST when we have a provider URL to redirect to. -->
|
||||
<div v-if="fiatRedirectUrl" class="space-y-3">
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Opens the provider's checkout in a new tab. Your ticket
|
||||
appears in My Tickets once the payment settles.
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="openFiatCheckout" class="w-full">
|
||||
<ExternalLink class="w-4 h-4 mr-2" />
|
||||
Open {{ fiatProviderLabel }} checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
@click="handlePurchase"
|
||||
:disabled="isLoading || !canPurchase"
|
||||
:disabled="isLoading || isFiatPending || (paymentMethod === 'lightning' && !canPurchase)"
|
||||
class="w-full"
|
||||
>
|
||||
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
||||
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2">⚡</span>
|
||||
<span v-else-if="paymentMethod === 'fiat'" class="flex items-center gap-2">
|
||||
<CreditCard class="w-4 h-4" />
|
||||
Continue to {{ event.fiat_currency ?? 'fiat' }} checkout
|
||||
</span>
|
||||
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
||||
<Zap class="w-4 h-4" />
|
||||
Pay with Wallet
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const selectedEvent = ref<{
|
|||
name: string
|
||||
price_per_ticket: number
|
||||
currency: string
|
||||
allow_fiat?: boolean
|
||||
fiat_currency?: string
|
||||
} | null>(null)
|
||||
|
||||
const showEventDialog = ref(false)
|
||||
|
|
@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
|
|||
name: string
|
||||
price_per_ticket: number
|
||||
currency: string
|
||||
allow_fiat?: boolean
|
||||
fiat_currency?: string
|
||||
}) {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedEvent.value = event
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue