From bbfaa1172a85872fefb7429ca31cf5133c679d1f Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 19 Feb 2026 02:21:41 +0000 Subject: [PATCH] Add unlisted event status: hidden from listings but accessible by URL - Backend: add 'unlisted' to schema enum and Zod validation; allow booking for unlisted events - Frontend: Event type and guards updated; unlisted events bookable, excluded from public listing/sitemap - Admin: badge, status dropdown, Make Unlisted / Make Public / Unpublish actions; scanner/emails/tickets include unlisted Co-authored-by: Cursor --- backend/src/db/schema.ts | 2 +- backend/src/routes/events.ts | 2 +- backend/src/routes/tickets.ts | 2 +- .../src/app/(public)/book/[eventId]/page.tsx | 2 +- .../events/[id]/EventDetailClient.tsx | 2 +- .../src/app/(public)/events/[id]/page.tsx | 2 +- frontend/src/app/admin/emails/page.tsx | 2 +- frontend/src/app/admin/events/page.tsx | 39 +++++++++++++++++-- frontend/src/app/admin/scanner/page.tsx | 7 ++-- frontend/src/app/admin/tickets/page.tsx | 2 +- frontend/src/lib/api.ts | 2 +- 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 82d7bf1..7eb2d12 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', { price: real('price').notNull().default(0), currency: text('currency').notNull().default('PYG'), capacity: integer('capacity').notNull().default(50), - status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), + status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), bannerUrl: text('banner_url'), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingUrl: text('external_booking_url'), diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index b4f2239..ebc383f 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -75,7 +75,7 @@ const baseEventSchema = z.object({ price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), currency: z.string().default('PYG'), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), - status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'), + status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), // Accept relative paths (/uploads/...) or full URLs bannerUrl: z.string().optional().nullable().or(z.literal('')), // External booking support - accept boolean or number (0/1 from DB) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 3b9f92e..7456779 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -69,7 +69,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { return c.json({ error: 'Event not found' }, 404); } - if (event.status !== 'published') { + if (!['published', 'unlisted'].includes(event.status)) { return c.json({ error: 'Event is not available for booking' }, 400); } diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8675a3b..d8c8e89 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -145,7 +145,7 @@ export default function BookingPage() { paymentOptionsApi.getForEvent(params.eventId as string), ]) .then(([eventRes, paymentRes]) => { - if (!eventRes.event || eventRes.event.status !== 'published') { + if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) { toast.error('Event is not available for booking'); router.push('/events'); return; diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 0b8a1c6..87b4348 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -60,7 +60,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail const isCancelled = event.status === 'cancelled'; // Only calculate isPastEvent after mount to avoid hydration mismatch const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; - const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; + const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted'); // Booking card content - reused for mobile and desktop positions const BookingCardContent = () => ( diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 6b6bc9b..9d78063 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -20,7 +20,7 @@ interface Event { price: number; currency: string; capacity: number; - status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; bannerUrl?: string; availableSeats?: number; bookedCount?: number; diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 931601f..5da81d8 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -568,7 +568,7 @@ export default function AdminEmailsPage() { className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" > - {events.filter(e => e.status === 'published').map((event) => ( + {events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => ( diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index f2aa176..c803230 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -10,7 +10,7 @@ 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 } from '@heroicons/react/24/outline'; +import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, 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'; @@ -40,7 +40,7 @@ export default function AdminEventsPage() { price: number; currency: string; capacity: number; - status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; bannerUrl: string; externalBookingEnabled: boolean; externalBookingUrl: string; @@ -225,8 +225,8 @@ export default function AdminEventsPage() { const getStatusBadge = (status: string) => { const styles: Record = { - draft: 'badge-gray', published: 'badge-success', cancelled: 'badge-danger', - completed: 'badge-info', archived: 'badge-gray', + draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning', + cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray', }; return {status}; }; @@ -359,6 +359,7 @@ export default function AdminEventsPage() { className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"> + @@ -515,6 +516,21 @@ export default function AdminEventsPage() { + {(event.status === 'draft' || event.status === 'published') && ( + handleStatusChange(event, 'unlisted')}> + Make Unlisted + + )} + {event.status === 'unlisted' && ( + handleStatusChange(event, 'published')}> + Make Public + + )} + {(event.status === 'published' || event.status === 'unlisted') && ( + handleStatusChange(event, 'draft')}> + Unpublish + + )} handleDuplicate(event)}> Duplicate @@ -588,6 +604,21 @@ export default function AdminEventsPage() { Publish )} + {(event.status === 'draft' || event.status === 'published') && ( + handleStatusChange(event, 'unlisted')}> + Make Unlisted + + )} + {event.status === 'unlisted' && ( + handleStatusChange(event, 'published')}> + Make Public + + )} + {(event.status === 'published' || event.status === 'unlisted') && ( + handleStatusChange(event, 'draft')}> + Unpublish + + )} {event.status === 'published' && ( handleSetFeatured(featuredEventId === event.id ? null : event.id)}> diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 47f20e4..f17895f 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -671,10 +671,11 @@ export default function AdminScannerPage() { // Load events useEffect(() => { - eventsApi.getAll({ status: 'published' }) + eventsApi.getAll() .then((res) => { - setEvents(res.events); - const upcoming = res.events.filter((e) => new Date(e.startDatetime) >= new Date()); + const bookable = res.events.filter((e) => e.status === 'published' || e.status === 'unlisted'); + setEvents(bookable); + const upcoming = bookable.filter((e) => new Date(e.startDatetime) >= new Date()); if (upcoming.length === 1) { setSelectedEventId(upcoming[0].id); } diff --git a/frontend/src/app/admin/tickets/page.tsx b/frontend/src/app/admin/tickets/page.tsx index 4377f2e..43f53f9 100644 --- a/frontend/src/app/admin/tickets/page.tsx +++ b/frontend/src/app/admin/tickets/page.tsx @@ -168,7 +168,7 @@ export default function AdminTicketsPage() { onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })} className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]" required> - {events.filter(e => e.status === 'published').map((event) => ( + {events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => ( ))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2475a72..22614b5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -516,7 +516,7 @@ export interface Event { price: number; currency: string; capacity: number; - status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; bannerUrl?: string; externalBookingEnabled?: boolean; externalBookingUrl?: string;