diff --git a/backend/src/index.ts b/backend/src/index.ts index fe47c13..9532ae7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -673,11 +673,21 @@ const openApiSpec = { }, }, }, + '/api/events/next': { + get: { + tags: ['Events'], + summary: 'Get next upcoming event (chronological)', + description: 'Get the single earliest upcoming published event, ignoring featured promotion.', + responses: { + 200: { description: 'Next event or null' }, + }, + }, + }, '/api/events/next/upcoming': { get: { tags: ['Events'], summary: 'Get next upcoming event', - description: 'Get the single next upcoming published event.', + description: 'Get the featured event if valid, otherwise the single next upcoming published event.', responses: { 200: { description: 'Next event or null' }, }, diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 8561381..07fe638 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -294,6 +294,45 @@ async function getEventTicketCount(eventId: string): Promise { return ticketCount?.count || 0; } +// Get the earliest upcoming published event with ticket counts (ignores featured promotion) +async function getNextChronologicalUpcoming(): Promise { + const now = getNow(); + const event = await dbGet( + (db as any) + .select() + .from(events) + .where( + and( + eq((events as any).status, 'published'), + gte((events as any).startDatetime, now) + ) + ) + .orderBy((events as any).startDatetime) + .limit(1) + ); + + if (!event) { + return null; + } + + const bookedCount = await getEventTicketCount(event.id); + const normalized = normalizeEvent(event); + return { + ...normalized, + bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + }; +} + +// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion +eventsRouter.get('/next', async (c) => { + const event = await getNextChronologicalUpcoming(); + if (!event) { + return c.json({ event: null }); + } + return c.json({ event: { ...event, isFeatured: false } }); +}); + // Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming eventsRouter.get('/next/upcoming', async (c) => { const now = getNow(); @@ -357,34 +396,13 @@ eventsRouter.get('/next/upcoming', async (c) => { } // Fallback: get the next upcoming published event - const event = await dbGet( - (db as any) - .select() - .from(events) - .where( - and( - eq((events as any).status, 'published'), - gte((events as any).startDatetime, now) - ) - ) - .orderBy((events as any).startDatetime) - .limit(1) - ); - + const event = await getNextChronologicalUpcoming(); + if (!event) { return c.json({ event: null }); } - - const bookedCount = await getEventTicketCount(event.id); - const normalized = normalizeEvent(event); - return c.json({ - event: { - ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), - isFeatured: false, - }, - }); + + return c.json({ event: { ...event, isFeatured: false } }); }); // Create event (admin/organizer only) diff --git a/frontend/src/app/(public)/featured/page.tsx b/frontend/src/app/(public)/featured/page.tsx new file mode 100644 index 0000000..b5a90df --- /dev/null +++ b/frontend/src/app/(public)/featured/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +async function getFeaturedEventSlug(): Promise { + try { + const revalidateSeconds = + parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600; + const response = await fetch(`${apiUrl}/api/events/next/upcoming`, { + next: { tags: ['next-event'], revalidate: revalidateSeconds }, + }); + if (!response.ok) return null; + const data = await response.json(); + return data.event?.slug || null; + } catch { + return null; + } +} + +export default async function FeaturedEventPage() { + const slug = await getFeaturedEventSlug(); + redirect(slug ? `/events/${slug}` : '/events'); +} diff --git a/frontend/src/app/(public)/next/page.tsx b/frontend/src/app/(public)/next/page.tsx new file mode 100644 index 0000000..65ec2e8 --- /dev/null +++ b/frontend/src/app/(public)/next/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +async function getNextEventSlug(): Promise { + try { + const revalidateSeconds = + parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600; + const response = await fetch(`${apiUrl}/api/events/next`, { + next: { tags: ['next-event'], revalidate: revalidateSeconds }, + }); + if (!response.ok) return null; + const data = await response.json(); + return data.event?.slug || null; + } catch { + return null; + } +} + +export default async function NextEventPage() { + const slug = await getNextEventSlug(); + redirect(slug ? `/events/${slug}` : '/events'); +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index c0e5762..70168eb 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -58,6 +58,18 @@ export default async function sitemap(): Promise { changeFrequency: 'daily', priority: 0.9, }, + { + url: `${siteUrl}/next`, + lastModified: now, + changeFrequency: 'daily', + priority: 0.8, + }, + { + url: `${siteUrl}/featured`, + lastModified: now, + changeFrequency: 'daily', + priority: 0.8, + }, { url: `${siteUrl}/community`, lastModified: now, diff --git a/frontend/src/lib/api/events.ts b/frontend/src/lib/api/events.ts index f2c420b..470b49d 100644 --- a/frontend/src/lib/api/events.ts +++ b/frontend/src/lib/api/events.ts @@ -11,6 +11,8 @@ export const eventsApi = { getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`), + getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'), + getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'), create: (data: Partial) =>