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 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue