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 <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-19 02:21:41 +00:00
parent 958181e049
commit bbfaa1172a
11 changed files with 48 additions and 16 deletions

View File

@@ -568,7 +568,7 @@ export default function AdminEmailsPage() {
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">Choose an event</option>
{events.filter(e => e.status === 'published').map((event) => (
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option>

View File

@@ -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<string, string> = {
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 <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
@@ -359,6 +359,7 @@ export default function AdminEventsPage() {
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>
@@ -515,6 +516,21 @@ export default function AdminEventsPage() {
<PencilIcon className="w-4 h-4" />
</button>
<MoreMenu>
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
<DropdownItem onClick={() => handleDuplicate(event)}>
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
</DropdownItem>
@@ -588,6 +604,21 @@ export default function AdminEventsPage() {
Publish
</DropdownItem>
)}
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
{event.status === 'published' && (
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
<StarIcon className="w-4 h-4 mr-2" />

View File

@@ -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);
}

View File

@@ -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>
<option value="">Select an event</option>
{events.filter(e => e.status === 'published').map((event) => (
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option>
))}
</select>