472 lines
22 KiB
TypeScript
472 lines
22 KiB
TypeScript
'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>
|
||
);
|
||
}
|