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
This commit is contained in:
Padreug 2026-09-06 19:43:10 +02:00
commit 5d3ebcc5e4

View file

@ -41,7 +41,15 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.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 { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
@ -124,7 +132,11 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), 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"), fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).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 // mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably — accept both. // extension uses 'sat' and 'sats' interchangeably — accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats' 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['fiat_currency'], path: ['fiat_currency'],
@ -173,7 +185,7 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false, payment_methods: ['lightning'],
fiat_currency: 'USD', fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 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 { interface BannerImage extends UploadedImage {
isPrimary: boolean isPrimary: boolean
} }
@ -243,7 +291,7 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false, payment_methods: paymentMethodsOf(event),
fiat_currency: event.fiat_currency ?? 'USD', fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
@ -354,11 +402,12 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a true→false flip propagates on edit; // allow_fiat always sends (derived from the rail list) so a
// fiat_currency only sends when fiat is on (no point persisting a // true→false flip propagates on edit; fiat_currency only sends when
// rail-currency the backend won't use). // fiat is on (no point persisting a rail-currency the backend won't use).
eventData.allow_fiat = formValues.allow_fiat const acceptsFiat = formValues.payment_methods.includes('fiat')
if (formValues.allow_fiat && formValues.fiat_currency) { eventData.allow_fiat = acceptsFiat
if (acceptsFiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency eventData.fiat_currency = formValues.fiat_currency
} }
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
@ -371,6 +420,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// survive the round-trip. // survive the round-trip.
eventData.extra = { eventData.extra = {
...(props.event?.extra ?? {}), ...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications, email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications, nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
@ -660,26 +710,87 @@ const handleOpenChange = (open: boolean) => {
</div> </div>
<!-- ── Payment methods ──────────────────────────────────── --> <!-- ── Payment methods ──────────────────────────────────── -->
<div class="space-y-3"> <FormField name="payment_methods">
<div> <FormItem class="space-y-3">
<p class="text-sm font-medium">Payment methods</p> <div>
<p class="text-xs text-muted-foreground"> <FormLabel class="text-sm font-medium">Payment methods</FormLabel>
Lightning is always available. Enable fiat to also accept <FormDescription class="text-xs">
card and bank payments through your configured provider. Pick the rails buyers can pay with. Turn Lightning off for a
</p> card-only sale.
</div> </FormDescription>
<div class="flex items-center gap-2 text-sm text-muted-foreground"> </div>
<Zap class="w-4 h-4" /> <div class="space-y-2">
<span>Lightning — always on</span> <FormField
</div> v-for="opt in PAYMENT_METHOD_OPTIONS"
<FiatToggleField :key="opt.id"
allow-fiat-field="allow_fiat" v-slot="{ value, handleChange }"
fiat-currency-field="fiat_currency" type="checkbox"
:denomination="form.values.currency ?? 'sat'" :value="opt.id"
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')" :unchecked-value="false"
:disabled="isLoading" name="payment_methods"
/> >
</div> <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 <!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on sends email + NIP-04 Nostr DM confirmations on