Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments.
Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -121,3 +121,9 @@ HOLD_THRESHOLD_HOURS=72
|
||||
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
|
||||
HOLD_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
# Ended-Event Payment Sweep
|
||||
# Once an event is over, any unconfirmed payment (pending / pending_approval /
|
||||
# on_hold) is auto-rejected and its ticket cancelled. No email is sent.
|
||||
# How often this sweep runs, in milliseconds (default: 900000 = 15 min)
|
||||
EVENT_END_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep } from './lib/holdSweep.js';
|
||||
import { startEventEndSweep } from './lib/eventEndSweep.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
@@ -1936,6 +1937,9 @@ startBookingCleanup();
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Periodically auto-reject unconfirmed payments once their event is over (no email).
|
||||
startEventEndSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Auto-reject unconfirmed payments once their event is over.
|
||||
//
|
||||
// After an event ends, any booking whose payment was never confirmed
|
||||
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
|
||||
// This job silently fails those payments and cancels their tickets so they stop
|
||||
// lingering as "pending" forever. It deliberately sends NO email — unlike the
|
||||
// admin reject route, this is a housekeeping sweep and users are not notified.
|
||||
//
|
||||
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
|
||||
// the past. Updates are guarded by the current status so re-running is a no-op.
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { getNow } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
|
||||
// Payment statuses that represent an unconfirmed booking.
|
||||
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
|
||||
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
|
||||
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
|
||||
|
||||
/**
|
||||
* Fail unconfirmed payments (and cancel their tickets) for events that have
|
||||
* already ended. Returns the number of payments rejected.
|
||||
*/
|
||||
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
|
||||
// Pull candidate rows first, then decide "ended" in JS so the comparison works
|
||||
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
|
||||
const rows = await dbAll<{
|
||||
paymentId: string;
|
||||
ticketId: string;
|
||||
endDatetime: string | Date | null;
|
||||
startDatetime: string | Date | null;
|
||||
}>(
|
||||
(db as any)
|
||||
.select({
|
||||
paymentId: (payments as any).id,
|
||||
ticketId: (tickets as any).id,
|
||||
endDatetime: (events as any).endDatetime,
|
||||
startDatetime: (events as any).startDatetime,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
|
||||
.where(and(
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
))
|
||||
);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const ended = rows.filter((r) => {
|
||||
const ref = r.endDatetime || r.startDatetime;
|
||||
if (!ref) return false;
|
||||
return new Date(ref as any).getTime() < nowMs;
|
||||
});
|
||||
|
||||
if (ended.length === 0) return 0;
|
||||
|
||||
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
|
||||
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
|
||||
const now = getNow();
|
||||
|
||||
// Fail the payments. The status guard keeps this idempotent and avoids
|
||||
// clobbering anything that changed since we read the candidates.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
|
||||
.where(and(
|
||||
inArray((payments as any).id, paymentIds),
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
));
|
||||
|
||||
// Cancel the associated tickets, freeing any seats they still hold.
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
|
||||
`cancelled ${ticketIds.length} ticket(s).`
|
||||
);
|
||||
return paymentIds.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
|
||||
* events. Each run is guarded by a distributed lock so that, across multiple
|
||||
* replicas, only one instance does the work per interval.
|
||||
*/
|
||||
export function startEventEndSweep(): void {
|
||||
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
|
||||
rejectUnconfirmedPaymentsForEndedEvents()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[EventEndSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 60_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopEventEndSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ export function BookingFormStep({
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, paymentMethod: method.id }))}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
|
||||
formData.paymentMethod === method.id
|
||||
? 'border-primary-yellow bg-primary-yellow/10'
|
||||
@@ -377,6 +377,9 @@ export function BookingFormStep({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{errors.paymentMethod && (
|
||||
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Terms & Privacy agreement */}
|
||||
@@ -430,7 +433,7 @@ export function BookingFormStep({
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={submitting}
|
||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms}
|
||||
>
|
||||
{formData.paymentMethod === 'cash'
|
||||
? t('booking.form.reserveSpot')
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface BookingFormData {
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
paymentMethod: PaymentMethod;
|
||||
// Empty until the user explicitly picks a method (no default selection).
|
||||
paymentMethod: PaymentMethod | '';
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function BookingPage() {
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: 'cash',
|
||||
paymentMethod: '',
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
@@ -130,18 +130,7 @@ export default function BookingPage() {
|
||||
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
|
||||
});
|
||||
setPaymentConfig(paymentRes.paymentOptions);
|
||||
|
||||
// Set default payment method based on what's enabled
|
||||
const config = paymentRes.paymentOptions;
|
||||
if (config.lightningEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
|
||||
} else if (config.cashEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
|
||||
} else if (config.bankTransferEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
|
||||
} else if (config.tpagoEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
|
||||
}
|
||||
// No payment method is pre-selected; the user must choose one.
|
||||
})
|
||||
.catch(() => router.push('/events'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -216,6 +205,15 @@ export default function BookingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Payment method must be explicitly chosen and currently enabled
|
||||
const availableMethods = buildPaymentMethods(paymentConfig, locale);
|
||||
if (
|
||||
!formData.paymentMethod ||
|
||||
!availableMethods.some((m) => m.id === formData.paymentMethod)
|
||||
) {
|
||||
newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired');
|
||||
}
|
||||
|
||||
// Validate additional attendees (if multi-ticket)
|
||||
attendees.forEach((attendee, index) => {
|
||||
if (!attendee.firstName.trim() || attendee.firstName.length < 2) {
|
||||
@@ -304,7 +302,7 @@ export default function BookingPage() {
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
paymentMethod: formData.paymentMethod,
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
||||
// Include attendees array for multi-ticket bookings
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
@@ -366,7 +364,7 @@ export default function BookingPage() {
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod,
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
setStep('success');
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isUnpaid,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
isActionableAttention,
|
||||
ticketAmount,
|
||||
shareTicket,
|
||||
isToday,
|
||||
@@ -58,7 +59,9 @@ export default function OverviewTab({
|
||||
// awaiting approval), ordered by soonest event.
|
||||
const attentionTicket = useMemo(() => {
|
||||
const candidates = activeTickets.filter(
|
||||
(t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)
|
||||
(t) =>
|
||||
isActionableAttention(t) &&
|
||||
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
|
||||
);
|
||||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||||
candidates.sort((a, b) => {
|
||||
|
||||
@@ -35,6 +35,22 @@ export function isAwaitingApproval(ticket: { payment?: { status?: string } | nul
|
||||
return ticket.payment?.status === 'pending_approval';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attention banner (e.g. "your spot was released", "payment pending")
|
||||
* is still worth showing to the user. It only makes sense to nudge the user when
|
||||
* the event still exists, is still bookable (published/unlisted), and has not
|
||||
* already ended. This suppresses stale banners for deleted, unpublished,
|
||||
* cancelled/completed/archived, or past events.
|
||||
*/
|
||||
export function isActionableAttention(ticket: UserTicket): boolean {
|
||||
const event = ticket.event;
|
||||
if (!event) return false;
|
||||
if (event.status !== 'published' && event.status !== 'unlisted') return false;
|
||||
const refDate = event.endDatetime || event.startDatetime;
|
||||
if (!refDate) return false;
|
||||
return parseDate(refDate).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment the seat hold expires. null when paid/awaiting/cancelled or when
|
||||
* the data needed to compute it is missing.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, emailsApi, adminApi, paymentsApi, Ticket } from '@/lib/api';
|
||||
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
|
||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -48,10 +48,10 @@ import { TicketsTab } from './_tabs/TicketsTab';
|
||||
import { EmailTab } from './_tabs/EmailTab';
|
||||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||||
import { EventModals } from './_modals/EventModals';
|
||||
import EventFormModal from '../_components/EventFormModal';
|
||||
|
||||
export default function AdminEventDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const eventId = params.id as string;
|
||||
const { locale } = useLanguage();
|
||||
|
||||
@@ -116,6 +116,17 @@ export default function AdminEventDetailPage() {
|
||||
// Payment options state + handlers
|
||||
const payments = usePaymentOverrides(eventId, locale);
|
||||
|
||||
// Edit event modal (opens in place instead of redirecting to the list page)
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
siteSettingsApi
|
||||
.get()
|
||||
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Mobile-specific state
|
||||
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
@@ -484,12 +495,10 @@ export default function AdminEventDetailPage() {
|
||||
View Public
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/events?edit=${event.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
||||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile header overflow menu */}
|
||||
<div className="md:hidden flex-shrink-0">
|
||||
@@ -505,7 +514,7 @@ export default function AdminEventDetailPage() {
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
||||
@@ -796,6 +805,15 @@ export default function AdminEventDetailPage() {
|
||||
setPreviewHtml={setPreviewHtml}
|
||||
/>
|
||||
|
||||
<EventFormModal
|
||||
open={showEditForm}
|
||||
event={event}
|
||||
featuredEventId={featuredEventId}
|
||||
onFeaturedChange={setFeaturedEventId}
|
||||
onClose={() => setShowEditForm(false)}
|
||||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||||
/>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
'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 { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { parseDate, EVENT_TIMEZONE } 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;
|
||||
}
|
||||
|
||||
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: '',
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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 || '',
|
||||
});
|
||||
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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,13 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
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 { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import EventFormModal from './_components/EventFormModal';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const router = useRouter();
|
||||
@@ -24,50 +23,8 @@ export default function AdminEventsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [formData, setFormData] = useState<{
|
||||
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;
|
||||
}>({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft',
|
||||
bannerUrl: '',
|
||||
externalBookingEnabled: false,
|
||||
externalBookingUrl: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
@@ -115,116 +72,19 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSlugAliases([]);
|
||||
setFormData({
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
});
|
||||
const handleEdit = (event: Event) => {
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
const 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')}`;
|
||||
};
|
||||
|
||||
const loadSlugAliases = async (eventId: string) => {
|
||||
try {
|
||||
const { aliases } = await eventsApi.getSlugAliases(eventId);
|
||||
setSlugAliases(aliases);
|
||||
} catch (error) {
|
||||
setSlugAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (slug: string) => {
|
||||
if (!editingEvent) return;
|
||||
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
|
||||
try {
|
||||
await eventsApi.deleteSlugAlias(editingEvent.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 handleEdit = (event: 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 || '',
|
||||
});
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
loadSlugAliases(event.id);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
if (editingEvent) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
eventData.slug = formData.slug || undefined;
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
loadEvents();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save event');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const handleFormSaved = () => {
|
||||
handleCloseForm();
|
||||
loadEvents();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -299,210 +159,20 @@ export default function AdminEventsPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
|
||||
<Button onClick={() => { setEditingEvent(null); setShowForm(true); }} className="hidden md:flex">
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t('admin.events.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Event Form Modal */}
|
||||
{showForm && (
|
||||
<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">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<button onClick={() => { setShowForm(false); resetForm(); }}
|
||||
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>
|
||||
|
||||
{editingEvent && (
|
||||
<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>
|
||||
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id} relatedType="event" />
|
||||
|
||||
{editingEvent && editingEvent.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 !== null}
|
||||
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.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 === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== editingEvent.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]">
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<EventFormModal
|
||||
open={showForm}
|
||||
event={editingEvent}
|
||||
featuredEventId={featuredEventId}
|
||||
onFeaturedChange={setFeaturedEventId}
|
||||
onClose={handleCloseForm}
|
||||
onSaved={handleFormSaved}
|
||||
/>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
@@ -724,7 +394,7 @@ export default function AdminEventsPage() {
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={() => { resetForm(); setShowForm(true); }}
|
||||
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"bookingFailed": "Booking failed. Please try again.",
|
||||
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
|
||||
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number.",
|
||||
"paymentMethodRequired": "Please select a payment method.",
|
||||
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
|
||||
"rucInvalidCheckDigit": "RUC inválido. Verifique el número.",
|
||||
"paymentMethodRequired": "Por favor selecciona un método de pago.",
|
||||
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user