feat: organizers, meetups UI, Plausible analytics, and migration tooling

- Add organizer model/API, admin and public organizer pages, meetup cards
- Refresh events/home/contact; add calendar dialog and carousel components
- Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout
- Prisma migration, seed updates, baseline-and-migrate script

Made-with: Cursor
This commit is contained in:
bbe
2026-04-04 21:55:34 +02:00
parent 586b572f73
commit 78271ea110
37 changed files with 1555 additions and 301 deletions

View File

@@ -51,10 +51,11 @@ export const api = {
request<void>(`/posts/${id}`, { method: "DELETE" }),
// Meetups
getMeetups: (params?: { status?: string; admin?: boolean }) => {
getMeetups: (params?: { status?: string; admin?: boolean; organizerSlug?: string }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.admin) searchParams.set("admin", "true");
if (params?.organizerSlug) searchParams.set("organizerSlug", params.organizerSlug);
const qs = searchParams.toString();
return request<any[]>(`/meetups${qs ? `?${qs}` : ""}`);
},
@@ -103,6 +104,17 @@ export const api = {
deleteCategory: (id: string) =>
request<void>(`/categories/${id}`, { method: "DELETE" }),
// Organizers
getOrganizers: () => request<any[]>("/organizers"),
getOrganizerBySlug: (slug: string) =>
request<any>(`/organizers/by-slug/${encodeURIComponent(slug)}`),
createOrganizer: (data: { name: string; slug: string }) =>
request<any>("/organizers", { method: "POST", body: JSON.stringify(data) }),
updateOrganizer: (id: string, data: { name?: string; slug?: string }) =>
request<any>(`/organizers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteOrganizer: (id: string) =>
request<void>(`/organizers/${id}`, { method: "DELETE" }),
// Relays
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>

View File

@@ -51,3 +51,44 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
/**
* Format the stored meetup calendar date (YYYY-MM-DD) for display without shifting
* by the viewer's timezone. Date-only strings parsed as new Date("YYYY-MM-DD") are
* UTC midnight and show the wrong local day in western zones.
*/
export function formatMeetupCivilDate(dateStr: string): {
monthShort: string;
day: string;
full: string;
} | null {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return null;
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const dayNum = parts[2];
if (!year || !month || !dayNum) return null;
const ref = new Date(Date.UTC(year, month - 1, dayNum, 12, 0, 0));
return {
monthShort: ref
.toLocaleString("en-US", { ...UTC_CAL_OPTS, month: "short" })
.toUpperCase(),
day: String(ref.getUTCDate()),
full: ref.toLocaleString("en-US", {
...UTC_CAL_OPTS,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
};
}
/** Long single-line civil date for event detail (same rules as formatMeetupCivilDate). */
export function formatMeetupCivilDateLong(dateStr: string): string {
return formatMeetupCivilDate(dateStr)?.full ?? "—";
}