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;