Add stable /next and /featured URLs that redirect to the current event.

These short links always resolve to the latest upcoming or featured event so they can be shared without updating when events rotate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-27 05:35:52 +00:00
co-authored by Cursor
parent 613bd7be1d
commit f5fe87613f
6 changed files with 114 additions and 26 deletions
+11 -1
View File
@@ -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' },
},
+43 -25
View File
@@ -294,6 +294,45 @@ async function getEventTicketCount(eventId: string): Promise<number> {
return ticketCount?.count || 0;
}
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
async function getNextChronologicalUpcoming(): Promise<any | null> {
const now = getNow();
const event = await dbGet<any>(
(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<any>(
(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)
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
async function getFeaturedEventSlug(): Promise<string | null> {
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');
}
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
async function getNextEventSlug(): Promise<string | null> {
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');
}
+12
View File
@@ -58,6 +58,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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,
+2
View File
@@ -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<Event>) =>