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 61665790b3

View file

@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -33,12 +32,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } 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 { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' 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 { 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'
@ -129,7 +129,7 @@ const formSchema = toTypedSchema(
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too. // string so equal-date / later-time is enforced too.
if (!v.event_end_date) return if (v.event_end_date) {
const start = foldDateTime(v.event_start_date, v.event_start_time) const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time) const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) { if (start && end && end < start) {
@ -139,6 +139,17 @@ const formSchema = toTypedSchema(
message: 'End must be on or after start', 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: ['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 eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat / fiat_currency: always send so the toggle reads // allow_fiat always sends so a truefalse flip propagates on edit;
// both directions on edit (a truefalse flip must propagate). // 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 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.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
@ -558,7 +572,15 @@ const handleOpenChange = (open: boolean) => {
/> />
</div> </div>
<!-- Tickets (optional, visible) --> <!-- 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"> <div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem>
@ -584,7 +606,7 @@ const handleOpenChange = (open: boolean) => {
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger>
@ -601,45 +623,28 @@ const handleOpenChange = (open: boolean) => {
</FormItem> </FormItem>
</FormField> </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> </div>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fiat_currency"> <!-- Payment methods -->
<FormItem v-show="form.values.allow_fiat"> <div class="space-y-3">
<FormLabel>Fiat currency</FormLabel> <div>
<FormControl> <p class="text-sm font-medium">Payment methods</p>
<Select v-bind="componentField"> <p class="text-xs text-muted-foreground">
<SelectTrigger> Lightning is always available. Enable fiat to also accept
<SelectValue placeholder="USD" /> card and bank payments through your configured provider.
</SelectTrigger> </p>
<SelectContent> </div>
<SelectItem value="USD">USD</SelectItem> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<SelectItem value="EUR">EUR</SelectItem> <Zap class="w-4 h-4" />
<SelectItem value="GBP">GBP</SelectItem> <span>Lightning always on</span>
<SelectItem value="CHF">CHF</SelectItem> </div>
</SelectContent> <FiatToggleField
</Select> allow-fiat-field="allow_fiat"
</FormControl> fiat-currency-field="fiat_currency"
<FormMessage /> :denomination="form.values.currency ?? 'sat'"
</FormItem> :available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat')"
</FormField> :disabled="isLoading"
/>
</div> </div>
<!-- Actions --> <!-- Actions -->