Files
Spanglish/frontend/src/app/admin/events/_components/EventFormModal.tsx
T

472 lines
22 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 } 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;
price: number;
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: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
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 || '',
price: event.price, currency: event.currency, capacity: event.capacity,
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;
}
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,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
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 className="grid grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<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>
<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>
);
}