From 0c142884c7e48b2e5937463a3be2632f168992bc Mon Sep 17 00:00:00 2001 From: Michilis Date: Tue, 3 Feb 2026 19:24:00 +0000 Subject: [PATCH] feat: add featured event with automatic fallback - Add featured_event_id to site_settings (schema + migration) - Backend: featured event logic in /events/next/upcoming with auto-unset when event ends - Site settings: PUT supports featuredEventId, add PUT /featured-event for admin - Admin events: Set as featured checkbox in editor, star toggle in list, featured badge - Admin settings: Featured Event section with current event and remove/change links - API: siteSettingsApi.setFeaturedEvent(), Event.isFeatured, SiteSettings.featuredEventId - Homepage/linktree unchanged: still use getNextUpcoming (now returns featured or fallback) --- backend/src/db/migrate.ts | 12 ++ backend/src/db/schema.ts | 4 + backend/src/routes/events.ts | 106 +++++++++++++--- backend/src/routes/site-settings.ts | 85 ++++++++++++- backend/src/routes/tickets.ts | 2 +- .../src/app/(public)/book/[eventId]/page.tsx | 64 ++-------- frontend/src/app/admin/events/page.tsx | 99 ++++++++++++++- frontend/src/app/admin/settings/page.tsx | 119 +++++++++++++++++- frontend/src/lib/api.ts | 8 ++ 9 files changed, 421 insertions(+), 78 deletions(-) diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index bf789a9..816543f 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -388,6 +388,7 @@ async function migrate() { instagram_url TEXT, twitter_url TEXT, linkedin_url TEXT, + featured_event_id TEXT REFERENCES events(id), maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, @@ -396,6 +397,11 @@ async function migrate() { ) `); + // Add featured_event_id column to site_settings if it doesn't exist + try { + await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`); + } catch (e) { /* column may already exist */ } + // Legal pages table for admin-editable legal content await (db as any).run(sql` CREATE TABLE IF NOT EXISTS legal_pages ( @@ -748,6 +754,7 @@ async function migrate() { instagram_url VARCHAR(500), twitter_url VARCHAR(500), linkedin_url VARCHAR(500), + featured_event_id UUID REFERENCES events(id), maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, @@ -756,6 +763,11 @@ async function migrate() { ) `); + // Add featured_event_id column to site_settings if it doesn't exist + try { + await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`); + } catch (e) { /* column may already exist */ } + // Legal pages table for admin-editable legal content await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS legal_pages ( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4d348cc..0e72ab5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -283,6 +283,8 @@ export const sqliteSiteSettings = sqliteTable('site_settings', { instagramUrl: text('instagram_url'), twitterUrl: text('twitter_url'), linkedinUrl: text('linkedin_url'), + // Featured event - manually promoted event shown on homepage/linktree + featuredEventId: text('featured_event_id').references(() => sqliteEvents.id), // Other settings maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false), maintenanceMessage: text('maintenance_message'), @@ -563,6 +565,8 @@ export const pgSiteSettings = pgTable('site_settings', { instagramUrl: varchar('instagram_url', { length: 500 }), twitterUrl: varchar('twitter_url', { length: 500 }), linkedinUrl: varchar('linkedin_url', { length: 500 }), + // Featured event - manually promoted event shown on homepage/linktree + featuredEventId: uuid('featured_event_id').references(() => pgEvents.id), // Other settings maintenanceMode: pgInteger('maintenance_mode').notNull().default(0), maintenanceMessage: pgText('maintenance_message'), diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 8ed934a..a74e893 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js'; +import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js'; @@ -198,10 +198,92 @@ eventsRouter.get('/:id', async (c) => { }); }); -// Get next upcoming event (public) +// Helper function to get ticket count for an event +async function getEventTicketCount(eventId: string): Promise { + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, eventId), + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` + ) + ) + ); + return ticketCount?.count || 0; +} + +// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming eventsRouter.get('/next/upcoming', async (c) => { const now = getNow(); + // First, check if there's a featured event in site settings + const settings = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + + let featuredEvent = null; + let shouldUnsetFeatured = false; + + if (settings?.featuredEventId) { + // Get the featured event + featuredEvent = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, settings.featuredEventId)) + ); + + if (featuredEvent) { + // Check if featured event is still valid: + // 1. Must be published + // 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime) + const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime; + const isPublished = featuredEvent.status === 'published'; + const hasNotEnded = eventEndTime >= now; + + if (!isPublished || !hasNotEnded) { + // Featured event is no longer valid - mark for unsetting + shouldUnsetFeatured = true; + featuredEvent = null; + } + } else { + // Featured event no longer exists + shouldUnsetFeatured = true; + } + } + + // If we need to unset the featured event, do it asynchronously + if (shouldUnsetFeatured && settings) { + // Unset featured event in background (don't await to avoid blocking response) + (db as any) + .update(siteSettings) + .set({ featuredEventId: null, updatedAt: now }) + .where(eq((siteSettings as any).id, settings.id)) + .then(() => { + console.log('Featured event auto-cleared (event ended or unpublished)'); + }) + .catch((err: any) => { + console.error('Failed to clear featured event:', err); + }); + } + + // If we have a valid featured event, return it + if (featuredEvent) { + const bookedCount = await getEventTicketCount(featuredEvent.id); + const normalized = normalizeEvent(featuredEvent); + return c.json({ + event: { + ...normalized, + bookedCount, + availableSeats: normalized.capacity - bookedCount, + isFeatured: true, + }, + }); + } + + // Fallback: get the next upcoming published event const event = await dbGet( (db as any) .select() @@ -220,26 +302,14 @@ eventsRouter.get('/next/upcoming', async (c) => { return c.json({ event: null }); } - // Count confirmed AND checked_in tickets (checked_in were previously confirmed) - // This ensures check-in doesn't affect capacity/spots_left - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - + const bookedCount = await getEventTicketCount(event.id); const normalized = normalizeEvent(event); return c.json({ event: { ...normalized, - bookedCount: ticketCount?.count || 0, - availableSeats: normalized.capacity - (ticketCount?.count || 0), + bookedCount, + availableSeats: normalized.capacity - bookedCount, + isFeatured: false, }, }); }); diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index bc63fc7..2421ad5 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -1,8 +1,8 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, siteSettings } from '../db/index.js'; -import { eq } from 'drizzle-orm'; +import { db, dbGet, siteSettings, events } from '../db/index.js'; +import { eq, and, gte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js'; @@ -27,6 +27,7 @@ const updateSiteSettingsSchema = z.object({ instagramUrl: z.string().url().optional().nullable().or(z.literal('')), twitterUrl: z.string().url().optional().nullable().or(z.literal('')), linkedinUrl: z.string().url().optional().nullable().or(z.literal('')), + featuredEventId: z.string().optional().nullable(), maintenanceMode: z.boolean().optional(), maintenanceMessage: z.string().optional().nullable(), maintenanceMessageEs: z.string().optional().nullable(), @@ -52,6 +53,7 @@ siteSettingsRouter.get('/', async (c) => { instagramUrl: null, twitterUrl: null, linkedinUrl: null, + featuredEventId: null, maintenanceMode: false, maintenanceMessage: null, maintenanceMessageEs: null, @@ -104,6 +106,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit if (!existing) { // Create new settings record const id = generateId(); + + // Validate featured event if provided + if (data.featuredEventId) { + const featuredEvent = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.featuredEventId)) + ); + if (!featuredEvent || featuredEvent.status !== 'published') { + return c.json({ error: 'Featured event must exist and be published' }, 400); + } + } + const newSettings = { id, timezone: data.timezone || 'America/Asuncion', @@ -116,6 +129,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit instagramUrl: data.instagramUrl || null, twitterUrl: data.twitterUrl || null, linkedinUrl: data.linkedinUrl || null, + featuredEventId: data.featuredEventId || null, maintenanceMode: toDbBool(data.maintenanceMode || false), maintenanceMessage: data.maintenanceMessage || null, maintenanceMessageEs: data.maintenanceMessageEs || null, @@ -128,6 +142,16 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201); } + // Validate featured event if provided + if (data.featuredEventId) { + const featuredEvent = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.featuredEventId)) + ); + if (!featuredEvent || featuredEvent.status !== 'published') { + return c.json({ error: 'Featured event must exist and be published' }, 400); + } + } + // Update existing settings const updateData: Record = { ...data, @@ -151,4 +175,61 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit return c.json({ settings: updated, message: 'Settings updated successfully' }); }); +// Set featured event (admin only) - convenience endpoint for event editor +siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('json', z.object({ + eventId: z.string().nullable(), +})), async (c) => { + const { eventId } = c.req.valid('json'); + const user = c.get('user'); + const now = getNow(); + + // Validate event if provided + if (eventId) { + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, eventId)) + ); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + if (event.status !== 'published') { + return c.json({ error: 'Event must be published to be featured' }, 400); + } + } + + // Get or create settings + const existing = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + + if (!existing) { + // Create new settings record with featured event + const id = generateId(); + const newSettings = { + id, + timezone: 'America/Asuncion', + siteName: 'Spanglish', + featuredEventId: eventId, + maintenanceMode: 0, + updatedAt: now, + updatedBy: user.id, + }; + + await (db as any).insert(siteSettings).values(newSettings); + + return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); + } + + // Update existing settings + await (db as any) + .update(siteSettings) + .set({ + featuredEventId: eventId, + updatedAt: now, + updatedBy: user.id, + }) + .where(eq((siteSettings as any).id, existing.id)); + + return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); +}); + export default siteSettingsRouter; diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index abf55bb..0288413 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -25,7 +25,7 @@ const createTicketSchema = z.object({ phone: z.string().min(6).optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), - ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(), + ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')), // Optional: array of attendees for multi-ticket booking attendees: z.array(attendeeSchema).optional(), }); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 62ea1de..d15b3ad 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -110,43 +110,12 @@ export default function BookingPage() { const [errors, setErrors] = useState>>({}); - // RUC validation using modulo 11 algorithm - const validateRucCheckDigit = (ruc: string): boolean => { - const match = ruc.match(/^(\d{6,8})-(\d)$/); - if (!match) return false; - - const baseNumber = match[1]; - const checkDigit = parseInt(match[2], 10); - - // Modulo 11 algorithm for Paraguayan RUC - const weights = [2, 3, 4, 5, 6, 7, 2, 3]; - let sum = 0; - const digits = baseNumber.split('').reverse(); - - for (let i = 0; i < digits.length; i++) { - sum += parseInt(digits[i], 10) * weights[i]; - } - - const remainder = sum % 11; - const expectedCheckDigit = remainder < 2 ? 0 : 11 - remainder; - - return checkDigit === expectedCheckDigit; - }; + const rucPattern = /^\d{6,10}$/; - // Format RUC input: auto-insert hyphen before last digit + // Format RUC input: digits only, max 10 const formatRuc = (value: string): string => { - // Remove non-numeric characters - const digits = value.replace(/\D/g, ''); - - // Limit to 9 digits (8 base + 1 check) - const limited = digits.slice(0, 9); - - // Auto-insert hyphen before last digit if we have more than 6 digits - if (limited.length > 6) { - return `${limited.slice(0, -1)}-${limited.slice(-1)}`; - } - - return limited; + const digits = value.replace(/\D/g, '').slice(0, 10); + return digits; }; // Handle RUC input change @@ -160,19 +129,12 @@ export default function BookingPage() { } }; - // Validate RUC on blur + // Validate RUC on blur (optional field: 6–10 digits) const handleRucBlur = () => { - if (!formData.ruc) return; // Optional field, no validation if empty - - const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; - - if (!rucPattern.test(formData.ruc)) { + if (!formData.ruc) return; + const digits = formData.ruc.replace(/\D/g, ''); + if (digits.length > 0 && !rucPattern.test(digits)) { setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') }); - return; - } - - if (!validateRucCheckDigit(formData.ruc)) { - setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidCheckDigit') }); } }; @@ -275,13 +237,11 @@ export default function BookingPage() { newErrors.phone = t('booking.form.errors.phoneTooShort'); } - // RUC validation (optional field - only validate if filled) + // RUC validation (optional field - 6–10 digits if filled) if (formData.ruc.trim()) { - const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; - if (!rucPattern.test(formData.ruc)) { + const digits = formData.ruc.replace(/\D/g, ''); + if (!/^\d{6,10}$/.test(digits)) { newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); - } else if (!validateRucCheckDigit(formData.ruc)) { - newErrors.ruc = t('booking.form.errors.rucInvalidCheckDigit'); } } @@ -429,7 +389,7 @@ export default function BookingPage() { phone: formData.phone, preferredLanguage: formData.preferredLanguage, paymentMethod: formData.paymentMethod, - ...(formData.ruc.trim() && { ruc: formData.ruc }), + ...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }), // Include attendees array for multi-ticket bookings ...(allAttendees.length > 1 && { attendees: allAttendees }), }); diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 9936472..e29b2e2 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -3,12 +3,13 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, Event } from '@/lib/api'; +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 { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; +import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -19,6 +20,8 @@ export default function AdminEventsPage() { const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [saving, setSaving] = useState(false); + const [featuredEventId, setFeaturedEventId] = useState(null); + const [settingFeatured, setSettingFeatured] = useState(null); const [formData, setFormData] = useState<{ title: string; @@ -60,6 +63,7 @@ export default function AdminEventsPage() { useEffect(() => { loadEvents(); + loadFeaturedEvent(); }, []); const loadEvents = async () => { @@ -73,6 +77,28 @@ export default function AdminEventsPage() { } }; + const loadFeaturedEvent = async () => { + try { + const { settings } = await siteSettingsApi.get(); + setFeaturedEventId(settings.featuredEventId || null); + } catch (error) { + // Ignore error - settings may not exist yet + } + }; + + const handleSetFeatured = async (eventId: string | null) => { + setSettingFeatured(eventId || 'clearing'); + try { + await siteSettingsApi.setFeaturedEvent(eventId); + setFeaturedEventId(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(null); + } + }; + const resetForm = () => { setFormData({ title: '', @@ -454,6 +480,44 @@ export default function AdminEventsPage() { relatedId={editingEvent?.id} relatedType="event" /> + + {/* Featured Event Section - Only show for published events when editing */} + {editingEvent && editingEvent.status === 'published' && ( +
+
+
+ +

+ Featured events are prominently displayed on the homepage and linktree +

+
+ +
+ {featuredEventId && featuredEventId !== editingEvent.id && ( +

+ Note: Another event is currently featured. Setting this event as featured will replace it. +

+ )} +
+ )}
@@ -534,6 +606,25 @@ export default function AdminEventsPage() { Publish )} + {event.status === 'published' && ( + + )} ([]); + const [featuredEvent, setFeaturedEvent] = useState(null); + const [clearingFeatured, setClearingFeatured] = useState(false); const [settings, setSettings] = useState({ timezone: 'America/Asuncion', @@ -33,6 +37,7 @@ export default function AdminSettingsPage() { instagramUrl: null, twitterUrl: null, linkedinUrl: null, + featuredEventId: null, maintenanceMode: false, maintenanceMessage: null, maintenanceMessageEs: null, @@ -50,6 +55,17 @@ export default function AdminSettingsPage() { ]); setSettings(settingsRes.settings); setTimezones(timezonesRes.timezones); + + // Load featured event details if one is set + if (settingsRes.settings.featuredEventId) { + try { + const { event } = await eventsApi.getById(settingsRes.settings.featuredEventId); + setFeaturedEvent(event); + } catch { + // Featured event may no longer exist + setFeaturedEvent(null); + } + } } catch (error) { toast.error('Failed to load settings'); } finally { @@ -57,6 +73,20 @@ export default function AdminSettingsPage() { } }; + const handleClearFeatured = async () => { + setClearingFeatured(true); + try { + await siteSettingsApi.setFeaturedEvent(null); + setSettings(prev => ({ ...prev, featuredEventId: null })); + setFeaturedEvent(null); + toast.success(locale === 'es' ? 'Evento destacado eliminado' : 'Featured event removed'); + } catch (error: any) { + toast.error(error.message || 'Failed to clear featured event'); + } finally { + setClearingFeatured(false); + } + }; + const handleSave = async () => { setSaving(true); try { @@ -146,6 +176,93 @@ export default function AdminSettingsPage() { + {/* Featured Event */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Evento Destacado' : 'Featured Event'} +

+

+ {locale === 'es' + ? 'El evento destacado aparece en la página de inicio y linktree' + : 'The featured event is displayed on the homepage and linktree'} +

+
+
+ + {featuredEvent ? ( +
+
+
+ {featuredEvent.bannerUrl && ( + {featuredEvent.title} + )} +
+

{featuredEvent.title}

+

+ {new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +

+

+ {locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status} +

+
+
+
+ + {locale === 'es' ? 'Cambiar' : 'Change'} + + +
+
+
+ ) : ( +
+

+ {locale === 'es' + ? 'No hay evento destacado. El próximo evento publicado se mostrará automáticamente.' + : 'No featured event set. The next upcoming published event will be shown automatically.'} +

+ + {locale === 'es' ? 'Ir a Eventos para destacar uno' : 'Go to Events to feature one'} + +
+ )} + +

+ {locale === 'es' + ? 'Cuando el evento destacado termine o se despublique, el sistema mostrará automáticamente el próximo evento.' + : 'When the featured event ends or is unpublished, the system will automatically show the next upcoming event.'} +

+
+
+ {/* Site Information */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 401f813..39337d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -439,6 +439,7 @@ export interface Event { externalBookingUrl?: string; bookedCount?: number; availableSeats?: number; + isFeatured?: boolean; createdAt: string; updatedAt: string; } @@ -955,6 +956,7 @@ export interface SiteSettings { instagramUrl?: string | null; twitterUrl?: string | null; linkedinUrl?: string | null; + featuredEventId?: string | null; maintenanceMode: boolean; maintenanceMessage?: string | null; maintenanceMessageEs?: string | null; @@ -978,6 +980,12 @@ export const siteSettingsApi = { getTimezones: () => fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'), + + setFeaturedEvent: (eventId: string | null) => + fetchApi<{ featuredEventId: string | null; message: string }>('/api/site-settings/featured-event', { + method: 'PUT', + body: JSON.stringify({ eventId }), + }), }; // ==================== Legal Pages Types ====================