Files
Spanglish/frontend/src/app/admin/events/_components/EventFormModal.tsx
T
MichilisandClaude Opus 5.5 e203fb6c74 Add per-event finance, event team permissions and public door sales state.
Event finance
- Finance tab on the event page: P&L summary with a revenue-to-result
  waterfall, costs and other income in one ledger, and the partner split
  with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close,
  Finalized) with what is left to do; closing the books goes through a
  checklist dialog that freezes the numbers.
- Expense modal shows only the fields each calculation type needs, a live
  preview with the event's real counts, and a category suggested from the
  description. Date inputs follow the UI language.
- Global Finance page: profit per event with outliers clipped and labelled,
  and a "Ready to close" list of past events whose books are still open.
- Calculation service (integer PYG, basis points) with pinned regression
  scenarios; PYG formatting centralised in lib/money with locale-aware
  separators.

Event team permissions
- Per-event members with role presets and requireEventPermission; the
  header stat "Confirmed" is relabelled "Not checked in yet", which is
  what it counts.

Public sales state
- One sales state (online, door, sold out, ended, external, cancelled) for
  the event page, listings and JSON-LD, with the door price and tenders
  shown only while people can still pay at the door.

Frontend unit tests run with vitest (npm test in frontend/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:11:46 +00:00

511 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker';
import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
interface EventFormData {
title: string;
titleEs: string;
slug: string;
description: string;
descriptionEs: string;
shortDescription: string;
shortDescriptionEs: string;
startDatetime: string;
endDatetime: string;
location: string;
locationUrl: string;
series: string;
price: number;
// Kept as the raw input so "empty" (not set -> falls back to price) stays
// distinguishable from 0 (a free walk-in).
walkInPrice: string;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string;
externalBookingEnabled: boolean;
externalBookingUrl: string;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
}
// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them
interface PresaleDefaults {
enabled: boolean;
minutesBefore: number;
}
const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 };
const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', series: '',
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
function isValidWalkInPrice(value: string): boolean {
const trimmed = value.trim();
if (trimmed === '') return true;
const n = Number(trimmed);
return Number.isInteger(n) && n >= 0;
}
function isoToLocalDatetime(isoString: string): string {
const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: EVENT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)!.value;
const h = get('hour') === '24' ? '00' : get('hour');
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
}
interface EventFormModalProps {
/** When true the modal is rendered. */
open: boolean;
/** The event being edited, or null to create a new event. */
event: Event | null;
/** Currently featured event id (owned by the parent page). */
featuredEventId: string | null;
/** Notify the parent when the featured event changes. */
onFeaturedChange: (id: string | null) => void;
/** Close the modal without saving. */
onClose: () => void;
/** Called after a successful create/update so the parent can refresh. */
onSaved: () => void;
}
/**
* Shared create/edit event modal used by both the events list page and the
* single-event detail page. Owns its own form state so it can be dropped in
* anywhere; the parent only supplies the event to edit and refresh callbacks.
*/
export default function EventFormModal({
open,
event,
featuredEventId,
onFeaturedChange,
onClose,
onSaved,
}: EventFormModalProps) {
const { t } = useLanguage();
const [formData, setFormData] = useState<EventFormData>(EMPTY_FORM);
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [saving, setSaving] = useState(false);
const [settingFeatured, setSettingFeatured] = useState(false);
const [presaleDefaults, setPresaleDefaults] = useState<PresaleDefaults>(FALLBACK_PRESALE_DEFAULTS);
useEffect(() => {
if (!open) return;
let cancelled = false;
// Site defaults fill in whatever the event hasn't overridden (null = inherit)
siteSettingsApi.get()
.then(({ settings }) => {
if (cancelled) return;
const defaults: PresaleDefaults = {
enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled,
minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
setPresaleDefaults(defaults);
setFormData((prev) => ({
...prev,
presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled,
presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore,
}));
})
.catch(() => { /* keep fallback defaults */ });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event]);
useEffect(() => {
if (!open) return;
if (event) {
setFormData({
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
description: event.description, descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '', series: event.series || '',
price: event.price, currency: event.currency, capacity: event.capacity,
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
});
loadSlugAliases(event.id);
} else {
setFormData(EMPTY_FORM);
setSlugAliases([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event]);
const loadSlugAliases = async (eventId: string) => {
try {
const { aliases } = await eventsApi.getSlugAliases(eventId);
setSlugAliases(aliases);
} catch (error) {
setSlugAliases([]);
}
};
const handleRemoveAlias = async (slug: string) => {
if (!event) return;
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
try {
await eventsApi.deleteSlugAlias(event.id, slug);
toast.success('Alias removed');
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
} catch (error: any) {
toast.error(error.message || 'Failed to remove alias');
}
};
const handleSetFeatured = async (eventId: string | null) => {
setSettingFeatured(true);
try {
await siteSettingsApi.setFeaturedEvent(eventId);
onFeaturedChange(eventId);
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to update featured event');
} finally {
setSettingFeatured(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
toast.error('External booking URL is required when external booking is enabled');
setSaving(false);
return;
}
if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) {
toast.error('External booking URL must be a valid HTTPS link');
setSaving(false);
return;
}
if (
formData.presaleClosureEnabled &&
(!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0)
) {
toast.error('Pre-sale closure time must be zero or more');
setSaving(false);
return;
}
if (!isValidWalkInPrice(formData.walkInPrice)) {
toast.error(t('admin.events.form.walkInPriceInvalid'));
setSaving(false);
return;
}
const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
series: formData.series.trim() || null,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
// Empty means "not set" and must be saved as null, never 0
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
// Saving from the modal always writes explicit values (overrides the site default)
presaleClosureEnabled: formData.presaleClosureEnabled,
presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore,
};
if (event) {
// Only send slug when editing so creates still auto-generate from title
eventData.slug = formData.slug || undefined;
await eventsApi.update(event.id, eventData);
toast.success('Event updated');
} else {
await eventsApi.create(eventData);
toast.success('Event created');
}
onSaved();
} catch (error: any) {
toast.error(error.message || 'Failed to save event');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-lg md:text-xl font-bold">
{event ? t('admin.events.edit') : t('admin.events.create')}
</h2>
<button onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Title (English)" value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
<Input label="Title (Spanish)" value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
</div>
{event && (
<div>
<Input label="URL Slug" value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="auto-generated from title" />
<p className="text-xs text-gray-500 mt-1">
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
. Changing the slug keeps the old one as a redirecting alias.
</p>
{slugAliases.length > 0 && (
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
<p className="text-sm font-medium mb-2">URL aliases</p>
<p className="text-xs text-gray-500 mb-2">
Old URLs that still redirect to the current slug. Removing one breaks those links.
</p>
<ul className="space-y-1">
{slugAliases.map((alias) => (
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono truncate">/events/{alias.slug}</span>
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
title="Remove alias">
<TrashIcon className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3} required />
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
<textarea value={formData.descriptionEs}
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
<textarea value={formData.shortDescription}
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
<textarea value={formData.shortDescriptionEs}
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
</div>
<Input label="Location" value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<div>
<Input label={t('admin.eventForm.series')} value={formData.series} maxLength={100} placeholder="Morning Club"
onChange={(e) => setFormData({ ...formData, series: e.target.value })} />
<p className="text-xs text-gray-500 mt-1">{t('admin.eventForm.seriesHint')}</p>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
value={formData.walkInPrice}
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
placeholder={t('admin.events.form.walkInPricePlaceholder', {
price: formatPrice(formData.price || 0, formData.currency),
})}
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Capacity" type="number" min="1" value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="unlisted">Unlisted</option>
<option value="cancelled">Cancelled</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">External Booking</label>
<p className="text-xs text-gray-500">Redirect users to an external platform</p>
</div>
<button type="button"
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{formData.externalBookingEnabled && (
<div>
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
placeholder="https://example.com/book" required />
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
</div>
)}
</div>
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">Pre-sale Closure</label>
<p className="text-xs text-gray-500">Stop online registration before the event starts</p>
</div>
<button type="button"
onClick={() => setFormData({ ...formData, presaleClosureEnabled: !formData.presaleClosureEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formData.presaleClosureEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
formData.presaleClosureEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{formData.presaleClosureEnabled && (
<DurationInput label="Close pre-sale this long before the start"
valueMinutes={formData.presaleCloseMinutesBefore}
onChange={(minutes) => setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} />
)}
<p className="text-xs text-gray-500">
Site default: {presaleDefaults.enabled
? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start`
: 'off'}
{' '}(Admin › Settings › General).
</p>
</div>
<MediaPicker value={formData.bannerUrl}
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
relatedId={event?.id} relatedType="event" />
{event && event.status === 'published' && (
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
</label>
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
</div>
<button type="button" disabled={settingFeatured}
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
featuredEventId === event.id ? 'bg-amber-500' : 'bg-gray-200'
}`}>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
featuredEventId === event.id ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{featuredEventId && featuredEventId !== event.id && (
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
Note: Another event is currently featured. Setting this event as featured will replace it.
</p>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
{event ? 'Update Event' : 'Create Event'}
</Button>
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">
Cancel
</Button>
</div>
</form>
</Card>
</div>
);
}