refactor(activities): adopt shared payment-rails pattern in CreateEventDialog

Split the bottom of the create/edit form into two semantic sections
that read in the canonical vocabulary:

  Pricing
    Tickets · Price · Price currency   ← renamed from bare "Currency"
  Payment methods
    Lightning — always on (informational chip)
    <FiatToggleField/>                  ← replaces the inline switch
                                          + raw fiat_currency dropdown

The toggle field handles the conditional dropdown (hide + auto-mirror
when the price denomination IS the fiat currency) and the disabled-
with-tooltip state when the user has no configured provider, so the
parent form just supplies field names + the denomination value.

Zod superRefine grows a check that requires `fiat_currency` only in
the surface where the toggle exposes the dropdown — `allow_fiat &&
currency === 'sat'`. Submit-time payload drops `fiat_currency` when
`allow_fiat` is off so we don't persist a rail-currency the backend
won't use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 18:37:11 +02:00
commit 985c10939d

View file

@ -24,7 +24,6 @@ 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,
@ -33,12 +32,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService'
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 { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
@ -129,14 +129,25 @@ const formSchema = toTypedSchema(
.superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too.
if (!v.event_end_date) return
const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) {
if (v.event_end_date) {
const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['event_end_date'],
message: 'End must be on or after start',
})
}
}
// When the price is in sats and the organizer also accepts fiat,
// they MUST choose a settle currency. Other price denominations
// mirror themselves into fiat_currency automatically.
if (v.allow_fiat && v.currency === 'sat' && !v.fiat_currency) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['event_end_date'],
message: 'End must be on or after start',
path: ['fiat_currency'],
message: 'Pick a fiat currency for buyers paying by card',
})
}
})
@ -325,10 +336,13 @@ 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 truefalse flip must propagate).
// allow_fiat always sends so a truefalse 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.fiat_currency) eventData.fiat_currency = formValues.fiat_currency
if (formValues.allow_fiat && 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
@ -558,88 +572,79 @@ const handleOpenChange = (open: boolean) => {
/>
</div>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Pricing -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Pricing</p>
<p class="text-xs text-muted-foreground">
Set what buyers see. Lightning charges happen in sats;
fiat amounts convert at checkout using current rates.
</p>
</div>
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Price currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</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>
<!-- 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')"
:disabled="isLoading"
/>
</div>
<!-- Actions -->