feat(events): guest checkout with email delivery, Stripe return flow, public ticket page #144
1 changed files with 141 additions and 30 deletions
feat(events): per-event payment methods in the create/edit dialog
Replace the "Also accept fiat" switch with a Lightning / Card checkbox group so an organizer can switch Lightning off for a card-only sale. Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays disabled with a tooltip when the organizer has no fiat provider; the fiat-currency picker appears when Card is on and the price is in sats. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
commit
5d3ebcc5e4
|
|
@ -41,7 +41,15 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
||||
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||
import { CreditCard } from 'lucide-vue-next'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
|
|
@ -124,7 +132,11 @@ 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),
|
||||
// Rails buyers may use. `allow_fiat` on the wire is derived from
|
||||
// this list (fiat ∈ list) so older extensions / clients keep working.
|
||||
payment_methods: z
|
||||
.array(z.enum(['lightning', 'fiat']))
|
||||
.min(1, 'Enable at least one payment method'),
|
||||
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),
|
||||
|
|
@ -152,7 +164,7 @@ const formSchema = toTypedSchema(
|
|||
// mirror themselves into fiat_currency automatically. The events
|
||||
// extension uses 'sat' and 'sats' interchangeably — accept both.
|
||||
const isSat = v.currency === 'sat' || v.currency === 'sats'
|
||||
if (v.allow_fiat && isSat && !v.fiat_currency) {
|
||||
if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['fiat_currency'],
|
||||
|
|
@ -173,7 +185,7 @@ const form = useForm({
|
|||
event_end_time: '',
|
||||
location: '',
|
||||
currency: 'sat',
|
||||
allow_fiat: false,
|
||||
payment_methods: ['lightning'],
|
||||
fiat_currency: 'USD',
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0,
|
||||
|
|
@ -184,6 +196,42 @@ const form = useForm({
|
|||
}
|
||||
})
|
||||
|
||||
/** Rails an existing event has on; legacy events (no list) derive
|
||||
* from allow_fiat. */
|
||||
function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> {
|
||||
const explicit = (event.extra?.payment_methods ?? []).filter(
|
||||
(m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat',
|
||||
)
|
||||
if (explicit.length > 0) return explicit
|
||||
return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning']
|
||||
}
|
||||
|
||||
const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders()
|
||||
// Refresh once so the Card checkbox reflects providers the organizer may
|
||||
// have just configured in LNbits.
|
||||
refreshFiatProviders()
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' },
|
||||
{ id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' },
|
||||
]
|
||||
|
||||
function isSatDenomination(d: string | undefined): boolean {
|
||||
return d === 'sat' || d === 'sats'
|
||||
}
|
||||
|
||||
// When the price is denominated in a fiat currency, the rail currency
|
||||
// MUST match it — silently mirror so the backend payload stays consistent.
|
||||
watch(
|
||||
() => form.values.currency,
|
||||
(d) => {
|
||||
if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) {
|
||||
form.setFieldValue('fiat_currency', d)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
interface BannerImage extends UploadedImage {
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
|
@ -243,7 +291,7 @@ async function populateFromEvent(event: TicketedEvent) {
|
|||
event_end_time: end.time,
|
||||
location: event.location ?? '',
|
||||
currency: event.currency ?? 'sat',
|
||||
allow_fiat: event.allow_fiat ?? false,
|
||||
payment_methods: paymentMethodsOf(event),
|
||||
fiat_currency: event.fiat_currency ?? 'USD',
|
||||
amount_tickets: event.amount_tickets ?? 0,
|
||||
price_per_ticket: event.price_per_ticket ?? 0,
|
||||
|
|
@ -354,11 +402,12 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
eventData.banner = null
|
||||
}
|
||||
if (formValues.currency) eventData.currency = formValues.currency
|
||||
// allow_fiat always sends so a true→false flip propagates on edit;
|
||||
// fiat_currency only sends when fiat is on (no point persisting a
|
||||
// rail-currency the backend won't use).
|
||||
eventData.allow_fiat = formValues.allow_fiat
|
||||
if (formValues.allow_fiat && formValues.fiat_currency) {
|
||||
// allow_fiat always sends (derived from the rail list) so a
|
||||
// true→false flip propagates on edit; fiat_currency only sends when
|
||||
// fiat is on (no point persisting a rail-currency the backend won't use).
|
||||
const acceptsFiat = formValues.payment_methods.includes('fiat')
|
||||
eventData.allow_fiat = acceptsFiat
|
||||
if (acceptsFiat && formValues.fiat_currency) {
|
||||
eventData.fiat_currency = formValues.fiat_currency
|
||||
}
|
||||
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||
|
|
@ -371,6 +420,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
// survive the round-trip.
|
||||
eventData.extra = {
|
||||
...(props.event?.extra ?? {}),
|
||||
payment_methods: formValues.payment_methods,
|
||||
email_notifications: formValues.email_notifications,
|
||||
nostr_notifications: formValues.nostr_notifications,
|
||||
notification_subject: formValues.notification_subject,
|
||||
|
|
@ -660,26 +710,87 @@ const handleOpenChange = (open: boolean) => {
|
|||
</div>
|
||||
|
||||
<!-- ── Payment methods ──────────────────────────────────── -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Payment methods</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Lightning is always available. Enable fiat to also accept
|
||||
card and bank payments through your configured provider.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Lightning — always on</span>
|
||||
</div>
|
||||
<FiatToggleField
|
||||
allow-fiat-field="allow_fiat"
|
||||
fiat-currency-field="fiat_currency"
|
||||
:denomination="form.values.currency ?? 'sat'"
|
||||
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
<FormField name="payment_methods">
|
||||
<FormItem class="space-y-3">
|
||||
<div>
|
||||
<FormLabel class="text-sm font-medium">Payment methods</FormLabel>
|
||||
<FormDescription class="text-xs">
|
||||
Pick the rails buyers can pay with. Turn Lightning off for a
|
||||
card-only sale.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<FormField
|
||||
v-for="opt in PAYMENT_METHOD_OPTIONS"
|
||||
:key="opt.id"
|
||||
v-slot="{ value, handleChange }"
|
||||
type="checkbox"
|
||||
:value="opt.id"
|
||||
:unchecked-value="false"
|
||||
name="payment_methods"
|
||||
>
|
||||
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
|
||||
<FormControl>
|
||||
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex">
|
||||
<Checkbox :model-value="false" disabled />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs">
|
||||
Your LNbits user has no fiat provider configured. Open
|
||||
LNbits → Account → Fiat providers and add Stripe, PayPal,
|
||||
or Square to enable card payments.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
v-else
|
||||
:model-value="Array.isArray(value) && value.includes(opt.id)"
|
||||
:disabled="isLoading"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
|
||||
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="fiat_currency">
|
||||
<FormItem
|
||||
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
|
||||
>
|
||||
<FormLabel>Fiat currency</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" :disabled="isLoading">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="USD" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||
:key="c"
|
||||
:value="c"
|
||||
>
|
||||
{{ c }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Ticket buyer notifications (collapsible). The backend
|
||||
sends email + NIP-04 Nostr DM confirmations on
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue