From 78271ea110b1d8da6ea864b346e59b35d40c5a59 Mon Sep 17 00:00:00 2001
From: bbe
Date: Sat, 4 Apr 2026 21:55:34 +0200
Subject: [PATCH] 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
---
.env.example | 15 +-
backend/package.json | 2 +
.../migration.sql | 47 +++++
backend/prisma/schema.prisma | 23 ++-
backend/prisma/seed.ts | 13 +-
backend/scripts/baseline-and-migrate.sh | 18 ++
backend/src/api/calendar.ts | 8 +-
backend/src/api/meetups.ts | 74 ++++++-
backend/src/api/organizers.ts | 132 +++++++++++++
backend/src/constants/organizer.ts | 2 +
backend/src/index.ts | 2 +
backend/src/lib/prismaMigrationHint.ts | 36 ++++
frontend/app/admin/events/page.tsx | 126 +++++++++++-
frontend/app/admin/organizers/page.tsx | 186 ++++++++++++++++++
frontend/app/admin/overview/page.tsx | 4 +-
frontend/app/contact/page.tsx | 54 +----
frontend/app/dashboard/layout.tsx | 5 +-
.../app/events/[id]/EventDetailClient.tsx | 56 ++++--
frontend/app/events/[id]/page.tsx | 9 +-
.../[slug]/OrganizerEventsClient.tsx | 153 ++++++++++++++
frontend/app/events/organizer/[slug]/page.tsx | 42 ++++
frontend/app/events/page.tsx | 91 ++-------
frontend/app/layout.tsx | 19 ++
frontend/app/page.tsx | 24 ++-
frontend/app/privacy/page.tsx | 91 +++++++--
frontend/app/terms/page.tsx | 100 ++++++++--
frontend/components/admin/AdminSidebar.tsx | 2 +
.../components/public/AddToCalendarDialog.tsx | 126 ++++++++++++
.../components/public/ContactChannelGrid.tsx | 75 +++++++
frontend/components/public/Footer.tsx | 17 ++
frontend/components/public/JsonLd.tsx | 10 +-
frontend/components/public/KnowledgeCards.tsx | 49 -----
frontend/components/public/MeetupCard.tsx | 67 +++++++
frontend/components/public/MeetupsSection.tsx | 43 ++--
.../public/UpcomingEventsCarousel.tsx | 80 ++++++++
frontend/lib/api.ts | 14 +-
frontend/lib/meetupEventTime.ts | 41 ++++
37 files changed, 1555 insertions(+), 301 deletions(-)
create mode 100644 backend/prisma/migrations/20260403120000_add_organizer/migration.sql
create mode 100755 backend/scripts/baseline-and-migrate.sh
create mode 100644 backend/src/api/organizers.ts
create mode 100644 backend/src/constants/organizer.ts
create mode 100644 backend/src/lib/prismaMigrationHint.ts
create mode 100644 frontend/app/admin/organizers/page.tsx
create mode 100644 frontend/app/events/organizer/[slug]/OrganizerEventsClient.tsx
create mode 100644 frontend/app/events/organizer/[slug]/page.tsx
create mode 100644 frontend/components/public/AddToCalendarDialog.tsx
create mode 100644 frontend/components/public/ContactChannelGrid.tsx
delete mode 100644 frontend/components/public/KnowledgeCards.tsx
create mode 100644 frontend/components/public/MeetupCard.tsx
create mode 100644 frontend/components/public/UpcomingEventsCarousel.tsx
diff --git a/.env.example b/.env.example
index da905fc..549ed86 100644
--- a/.env.example
+++ b/.env.example
@@ -4,9 +4,12 @@ ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
-# Database (path is relative to backend/prisma/ when using file: URLs — see Prisma docs)
-# Apply schema: from repo root run `npm run db:push`, or from backend run `npm run db:push`.
-# Do not run bare `npx prisma db push` from the repo root (no schema there; wrong Prisma version).
+# Database (path is relative to the backend working directory for file: URLs — keep deploy cwd consistent)
+# After pulling code: from backend run `npm run migrate:deploy` (or `npm run db:migrate`) so migrations
+# run in order. Do not rely on `db:push` for upgrades that add required columns to non-empty tables.
+#
+# If migrate fails with P3005 (schema not empty / no migration history, e.g. DB was created with db push),
+# from backend run once: `npm run db:baseline-and-migrate` (see scripts/baseline-and-migrate.sh).
DATABASE_URL="file:./dev.db"
# JWT
@@ -25,6 +28,12 @@ NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org
NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy
NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup
+# Plausible analytics (optional; both must be set or the script is omitted)
+# Tracked site domain (data-domain). Example: belgianbitcoinembassy.org
+NEXT_PUBLIC_PLAUSIBLE_DOMAIN=belgianbitcoinembassy.org
+# Plausible / custom analytics host origin, no trailing slash. Example: https://analytics.azzamo.net
+NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN=https://analytics.azzamo.net
+
# Message board (Lightning / LNbits) — backend
MESSAGE_PRICE_SATS=1000
LNBITS_API_KEY=
diff --git a/backend/package.json b/backend/package.json
index 95b9d90..ad06e70 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -6,6 +6,8 @@
"build": "tsc",
"start": "node dist/index.js",
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
+ "db:migrate": "dotenv -e ../.env -e .env -- prisma migrate deploy",
+ "db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
diff --git a/backend/prisma/migrations/20260403120000_add_organizer/migration.sql b/backend/prisma/migrations/20260403120000_add_organizer/migration.sql
new file mode 100644
index 0000000..2aca9a5
--- /dev/null
+++ b/backend/prisma/migrations/20260403120000_add_organizer/migration.sql
@@ -0,0 +1,47 @@
+-- CreateTable
+CREATE TABLE "Organizer" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
+
+CREATE UNIQUE INDEX "Organizer_slug_key" ON "Organizer"("slug");
+
+INSERT INTO "Organizer" ("id", "name", "slug", "createdAt", "updatedAt")
+VALUES (
+ '00000000-0000-4000-8000-000000000001',
+ 'Belgian Bitcoin Embassy',
+ 'belgian-bitcoin-embassy',
+ datetime('now'),
+ datetime('now')
+);
+
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Meetup" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "date" TEXT NOT NULL,
+ "time" TEXT NOT NULL,
+ "location" TEXT NOT NULL,
+ "link" TEXT,
+ "imageId" TEXT,
+ "status" TEXT NOT NULL DEFAULT 'DRAFT',
+ "featured" BOOLEAN NOT NULL DEFAULT false,
+ "visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
+ "organizerId" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "Meetup_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "Organizer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "organizerId", "status", "time", "title", "updatedAt", "visibility")
+SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", '00000000-0000-4000-8000-000000000001', "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
+DROP TABLE "Meetup";
+ALTER TABLE "new_Meetup" RENAME TO "Meetup";
+CREATE INDEX "Meetup_organizerId_idx" ON "Meetup"("organizerId");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 613ad56..d18b631 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -17,8 +17,17 @@ model User {
updatedAt DateTime @updatedAt
}
+model Organizer {
+ id String @id @default(uuid())
+ name String
+ slug String @unique
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ meetups Meetup[]
+}
+
model Meetup {
- id String @id @default(uuid())
+ id String @id @default(uuid())
title String
description String
date String
@@ -26,11 +35,13 @@ model Meetup {
location String
link String?
imageId String?
- status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
- featured Boolean @default(false)
- visibility String @default("PUBLIC") // PUBLIC, HIDDEN
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
+ featured Boolean @default(false)
+ visibility String @default("PUBLIC") // PUBLIC, HIDDEN
+ organizerId String
+ organizer Organizer @relation(fields: [organizerId], references: [id], onDelete: Restrict)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
model Media {
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index ca55298..9dd545b 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -56,6 +56,16 @@ async function main() {
});
}
+ const defaultOrganizer = await prisma.organizer.upsert({
+ where: { slug: 'belgian-bitcoin-embassy' },
+ update: {},
+ create: {
+ id: '00000000-0000-4000-8000-000000000001',
+ name: 'Belgian Bitcoin Embassy',
+ slug: 'belgian-bitcoin-embassy',
+ },
+ });
+
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
@@ -70,8 +80,9 @@ async function main() {
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
- status: 'UPCOMING',
+ status: 'PUBLISHED',
featured: true,
+ organizerId: defaultOrganizer.id,
},
});
}
diff --git a/backend/scripts/baseline-and-migrate.sh b/backend/scripts/baseline-and-migrate.sh
new file mode 100755
index 0000000..b7d8ddb
--- /dev/null
+++ b/backend/scripts/baseline-and-migrate.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Use when `npm run db:migrate` fails with P3005 (DB has tables but no migrate history, e.g. after db push).
+# Marks historical migrations as already applied, then runs `migrate deploy` to apply pending ones (e.g. organizer).
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+for migration_name in \
+ 20260331051150_add_user_username \
+ 20260331053518_add_meetup_visibility \
+ 20260331061812_add_user_username
+do
+ echo "Marking as applied: $migration_name"
+ # Ignore failures when already recorded or already baselined
+ dotenv -e ../.env -e .env -- prisma migrate resolve --applied "$migration_name" || true
+done
+
+echo "Applying any pending migrations..."
+dotenv -e ../.env -e .env -- prisma migrate deploy
diff --git a/backend/src/api/calendar.ts b/backend/src/api/calendar.ts
index d4080a3..65bf094 100644
--- a/backend/src/api/calendar.ts
+++ b/backend/src/api/calendar.ts
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
+import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -92,6 +93,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
orderBy: { date: 'asc' },
+ include: { organizer: true },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
@@ -129,8 +131,11 @@ router.get('/ics', async (_req: Request, res: Response) => {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
+ const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy';
lines.push(
- 'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
+ fold(
+ `ORGANIZER;CN=${escapeIcs(orgName)}:mailto:info@belgianbitcoinembassy.org`
+ )
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
@@ -156,6 +161,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
+ if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
diff --git a/backend/src/api/meetups.ts b/backend/src/api/meetups.ts
index 20ad9ab..28f7d7c 100644
--- a/backend/src/api/meetups.ts
+++ b/backend/src/api/meetups.ts
@@ -1,9 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
+import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
+import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
+const meetupInclude = { organizer: true } as const;
+
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -13,22 +17,42 @@ function incrementTitle(title: string): string {
return `${title} (copy)`;
}
+async function resolveOrganizerIdForCreate(organizerId: unknown): Promise {
+ if (typeof organizerId === 'string' && organizerId.trim()) {
+ return organizerId;
+ }
+ const def = await prisma.organizer.findUnique({ where: { slug: DEFAULT_ORGANIZER_SLUG } });
+ return def?.id ?? null;
+}
+
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
- const where: any = {};
+ const organizerSlug = req.query.organizerSlug as string | undefined;
+ const where: Record = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
+ if (organizerSlug) {
+ const org = await prisma.organizer.findUnique({ where: { slug: organizerSlug } });
+ if (!org) {
+ res.status(404).json({ error: 'Organizer not found' });
+ return;
+ }
+ where.organizerId = org.id;
+ }
+
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
+ include: meetupInclude,
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
+ if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -37,6 +61,7 @@ router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
+ include: meetupInclude,
});
if (!meetup) {
@@ -47,6 +72,7 @@ router.get('/:id', async (req: Request, res: Response) => {
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
+ if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -57,8 +83,19 @@ router.post(
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
- const { title, description, date, time, location, link, status, featured, imageId, visibility } =
- req.body;
+ const {
+ title,
+ description,
+ date,
+ time,
+ location,
+ link,
+ status,
+ featured,
+ imageId,
+ visibility,
+ organizerId,
+ } = req.body;
if (!title || !description || !date || !time || !location) {
res
@@ -67,6 +104,12 @@ router.post(
return;
}
+ const resolvedOrganizerId = await resolveOrganizerIdForCreate(organizerId);
+ if (!resolvedOrganizerId) {
+ res.status(500).json({ error: 'Default organizer is not configured' });
+ return;
+ }
+
const meetup = await prisma.meetup.create({
data: {
title,
@@ -79,7 +122,9 @@ router.post(
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
+ organizerId: resolvedOrganizerId,
},
+ include: meetupInclude,
});
res.status(201).json(meetup);
@@ -134,7 +179,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
+ organizerId: m.organizerId,
},
+ include: meetupInclude,
})
)
);
@@ -174,7 +221,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
+ organizerId: original.organizerId,
},
+ include: meetupInclude,
});
res.status(201).json(duplicate);
@@ -199,10 +248,21 @@ router.patch(
return;
}
- const { title, description, date, time, location, link, status, featured, imageId, visibility } =
- req.body;
+ const {
+ title,
+ description,
+ date,
+ time,
+ location,
+ link,
+ status,
+ featured,
+ imageId,
+ visibility,
+ organizerId,
+ } = req.body;
- const updateData: any = {};
+ const updateData: Record = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
@@ -213,10 +273,12 @@ router.patch(
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
+ if (organizerId !== undefined) updateData.organizerId = organizerId;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
+ include: meetupInclude,
});
res.json(updated);
diff --git a/backend/src/api/organizers.ts b/backend/src/api/organizers.ts
new file mode 100644
index 0000000..c8fabfe
--- /dev/null
+++ b/backend/src/api/organizers.ts
@@ -0,0 +1,132 @@
+import { Router, Request, Response } from 'express';
+import { prisma } from '../db/prisma';
+import { requireAuth, requireRole } from '../middleware/auth';
+import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
+
+const router = Router();
+
+router.get('/', async (_req: Request, res: Response) => {
+ try {
+ const organizers = await prisma.organizer.findMany({
+ orderBy: { name: 'asc' },
+ });
+ res.json(organizers);
+ } catch (err) {
+ console.error('List organizers error:', err);
+ if (respondIfOrganizerMigrationNeeded(err, res)) return;
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.get('/by-slug/:slug', async (req: Request, res: Response) => {
+ try {
+ const organizer = await prisma.organizer.findUnique({
+ where: { slug: req.params.slug as string },
+ });
+ if (!organizer) {
+ res.status(404).json({ error: 'Organizer not found' });
+ return;
+ }
+ res.json(organizer);
+ } catch (err) {
+ console.error('Get organizer by slug error:', err);
+ if (respondIfOrganizerMigrationNeeded(err, res)) return;
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.post(
+ '/',
+ requireAuth,
+ requireRole(['ADMIN', 'MODERATOR']),
+ async (req: Request, res: Response) => {
+ try {
+ const { name, slug } = req.body;
+ if (!name || !slug) {
+ res.status(400).json({ error: 'name and slug are required' });
+ return;
+ }
+
+ const organizer = await prisma.organizer.create({
+ data: { name, slug },
+ });
+
+ res.status(201).json(organizer);
+ } catch (err: any) {
+ if (err?.code === 'P2002') {
+ res.status(400).json({ error: 'An organizer with this slug already exists' });
+ return;
+ }
+ console.error('Create organizer error:', err);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+);
+
+router.patch(
+ '/:id',
+ requireAuth,
+ requireRole(['ADMIN', 'MODERATOR']),
+ async (req: Request, res: Response) => {
+ try {
+ const organizer = await prisma.organizer.findUnique({
+ where: { id: req.params.id as string },
+ });
+ if (!organizer) {
+ res.status(404).json({ error: 'Organizer not found' });
+ return;
+ }
+
+ const { name, slug } = req.body;
+ const updateData: { name?: string; slug?: string } = {};
+ if (name !== undefined) updateData.name = name;
+ if (slug !== undefined) updateData.slug = slug;
+
+ const updated = await prisma.organizer.update({
+ where: { id: req.params.id as string },
+ data: updateData,
+ });
+
+ res.json(updated);
+ } catch (err: any) {
+ if (err?.code === 'P2002') {
+ res.status(400).json({ error: 'An organizer with this slug already exists' });
+ return;
+ }
+ console.error('Update organizer error:', err);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+);
+
+router.delete(
+ '/:id',
+ requireAuth,
+ requireRole(['ADMIN']),
+ async (req: Request, res: Response) => {
+ try {
+ const organizer = await prisma.organizer.findUnique({
+ where: { id: req.params.id as string },
+ include: { _count: { select: { meetups: true } } },
+ });
+ if (!organizer) {
+ res.status(404).json({ error: 'Organizer not found' });
+ return;
+ }
+ if (organizer._count.meetups > 0) {
+ res.status(400).json({
+ error: `Cannot delete organizer: ${organizer._count.meetups} event(s) still reference it`,
+ });
+ return;
+ }
+
+ await prisma.organizer.delete({ where: { id: req.params.id as string } });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Delete organizer error:', err);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+);
+
+export default router;
diff --git a/backend/src/constants/organizer.ts b/backend/src/constants/organizer.ts
new file mode 100644
index 0000000..50ab6e1
--- /dev/null
+++ b/backend/src/constants/organizer.ts
@@ -0,0 +1,2 @@
+/** Slug for the default organizer row (seed + migration). */
+export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';
diff --git a/backend/src/index.ts b/backend/src/index.ts
index f27e477..8f9a921 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -10,6 +10,7 @@ import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
+import organizersRouter from './api/organizers';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
@@ -39,6 +40,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
+app.use('/api/organizers', organizersRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);
diff --git a/backend/src/lib/prismaMigrationHint.ts b/backend/src/lib/prismaMigrationHint.ts
new file mode 100644
index 0000000..ef88c9f
--- /dev/null
+++ b/backend/src/lib/prismaMigrationHint.ts
@@ -0,0 +1,36 @@
+import type { Response } from 'express';
+
+/**
+ * When the DB was never migrated for organizers (or db push failed), Prisma throws.
+ * Return a clear JSON error so operators know to run `prisma migrate deploy`, not `db push`.
+ */
+export function respondIfOrganizerMigrationNeeded(err: unknown, res: Response): boolean {
+ const e = err as {
+ code?: string;
+ meta?: { modelName?: string; table?: string; column?: string };
+ message?: string;
+ };
+ const msg = String(e?.message ?? '');
+
+ if (e?.code === 'P2021') {
+ const model = e.meta?.modelName ?? '';
+ const table = e.meta?.table ?? '';
+ if (model === 'Organizer' || table.includes('Organizer')) {
+ res.status(503).json({
+ error:
+ 'Database is missing the Organizer table. On the server run: cd backend && npm run migrate:deploy (use Prisma migrate, not db push).',
+ });
+ return true;
+ }
+ }
+
+ if (e?.code === 'P2022' && (msg.includes('organizerId') || e.meta?.column === 'organizerId')) {
+ res.status(503).json({
+ error:
+ 'Database is missing meetup organizer columns. On the server run: cd backend && npm run migrate:deploy (do not use prisma db push for this upgrade).',
+ });
+ return true;
+ }
+
+ return false;
+}
diff --git a/frontend/app/admin/events/page.tsx b/frontend/app/admin/events/page.tsx
index 6d7fa21..90735df 100644
--- a/frontend/app/admin/events/page.tsx
+++ b/frontend/app/admin/events/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
-import { formatDate } from "@/lib/utils";
+import { slugify } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Plus,
@@ -23,7 +23,7 @@ import {
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
-import { getMeetupStartUtc } from "@/lib/meetupEventTime";
+import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
interface Meetup {
id: string;
@@ -37,6 +37,8 @@ interface Meetup {
status: string;
featured: boolean;
visibility: string;
+ organizerId?: string;
+ organizer?: { id: string; name: string; slug: string };
createdAt: string;
updatedAt: string;
}
@@ -52,6 +54,7 @@ interface MeetupForm {
status: string;
featured: boolean;
visibility: string;
+ organizerId: string;
}
const emptyForm: MeetupForm = {
@@ -65,8 +68,17 @@ const emptyForm: MeetupForm = {
status: "DRAFT",
featured: false,
visibility: "PUBLIC",
+ organizerId: "",
};
+function defaultOrganizerId(organizers: { id: string; slug: string }[]): string {
+ return (
+ organizers.find((o) => o.slug === "belgian-bitcoin-embassy")?.id ||
+ organizers[0]?.id ||
+ ""
+ );
+}
+
// Statuses that can be manually set by an admin
const EDITABLE_STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "CANCELLED"] as const;
type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
@@ -229,10 +241,20 @@ export default function EventsPage() {
const [selected, setSelected] = useState>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
+ const [organizers, setOrganizers] = useState<{ id: string; name: string; slug: string }[]>([]);
+ const [showAddOrganizer, setShowAddOrganizer] = useState(false);
+ const [newOrgName, setNewOrgName] = useState("");
+ const [newOrgSlug, setNewOrgSlug] = useState("");
+ const [savingOrganizer, setSavingOrganizer] = useState(false);
+
const loadMeetups = async () => {
try {
- const data = await api.getMeetups({ admin: true });
+ const [data, orgs] = await Promise.all([
+ api.getMeetups({ admin: true }),
+ api.getOrganizers(),
+ ]);
setMeetups(data as Meetup[]);
+ setOrganizers(orgs);
} catch (err: any) {
setError(err.message);
} finally {
@@ -245,13 +267,38 @@ export default function EventsPage() {
}, []);
const openCreate = () => {
- setForm(emptyForm);
+ setForm({ ...emptyForm, organizerId: defaultOrganizerId(organizers) });
+ setShowAddOrganizer(false);
+ setNewOrgName("");
+ setNewOrgSlug("");
setEditingId(null);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
+ const handleCreateOrganizer = async () => {
+ const slug = newOrgSlug.trim() || slugify(newOrgName);
+ if (!newOrgName.trim() || !slug) return;
+ setSavingOrganizer(true);
+ setError("");
+ try {
+ const o = await api.createOrganizer({ name: newOrgName.trim(), slug });
+ setOrganizers((prev) => [...prev, o].sort((a, b) => a.name.localeCompare(b.name)));
+ setForm((f) => ({ ...f, organizerId: o.id }));
+ setShowAddOrganizer(false);
+ setNewOrgName("");
+ setNewOrgSlug("");
+ } catch (err: any) {
+ setError(err.message);
+ } finally {
+ setSavingOrganizer(false);
+ }
+ };
+
const openEdit = (meetup: Meetup) => {
+ setShowAddOrganizer(false);
+ setNewOrgName("");
+ setNewOrgSlug("");
setForm({
title: meetup.title,
description: meetup.description || "",
@@ -263,6 +310,7 @@ export default function EventsPage() {
status: meetup.status || "DRAFT",
featured: meetup.featured || false,
visibility: meetup.visibility || "PUBLIC",
+ organizerId: meetup.organizerId || meetup.organizer?.id || defaultOrganizerId(organizers),
});
setEditingId(meetup.id);
setShowForm(true);
@@ -284,6 +332,7 @@ export default function EventsPage() {
status: form.status,
featured: form.featured,
visibility: form.visibility,
+ organizerId: form.organizerId || undefined,
};
if (editingId) {
@@ -502,6 +551,61 @@ export default function EventsPage() {
Hidden
+
+
Organizer
+
+ setForm({ ...form, organizerId: e.target.value })}
+ className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full sm:flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40"
+ >
+ {organizers.length === 0 ? (
+ No organizers — add one in Organizers
+ ) : (
+ organizers.map((o) => (
+
+ {o.name}
+
+ ))
+ )}
+
+ setShowAddOrganizer((v) => !v)}
+ className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/80 hover:text-on-surface text-sm font-medium shrink-0"
+ >
+ {showAddOrganizer ? "Cancel add" : "Add new organizer"}
+
+
+ {showAddOrganizer && (
+
+ {
+ const name = e.target.value;
+ setNewOrgName(name);
+ setNewOrgSlug(slugify(name));
+ }}
+ className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
+ />
+ setNewOrgSlug(e.target.value)}
+ className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
+ />
+
+ {savingOrganizer ? "Creating…" : "Create and select"}
+
+
+ )}
+
External registration link{" "}
@@ -579,7 +683,12 @@ export default function EventsPage() {
{saving ? "Saving..." : "Save"}
@@ -761,13 +870,18 @@ export default function EventsPage() {
)}
+ {meetup.organizer?.name && (
+
+ {meetup.organizer.name}
+
+ )}
handlePatch(meetup.id, { status: v })}
/>
- {meetup.date ? formatDate(meetup.date) : "No date"}
+ {meetup.date ? formatMeetupCivilDateLong(meetup.date) : "No date"}
{meetup.location && ` · ${meetup.location}`}
diff --git a/frontend/app/admin/organizers/page.tsx b/frontend/app/admin/organizers/page.tsx
new file mode 100644
index 0000000..7380a81
--- /dev/null
+++ b/frontend/app/admin/organizers/page.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { slugify } from "@/lib/utils";
+import { Plus, Pencil, Trash2, X } from "lucide-react";
+
+interface OrganizerForm {
+ name: string;
+ slug: string;
+}
+
+export default function OrganizersPage() {
+ const [organizers, setOrganizers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState({ name: "", slug: "" });
+ const [saving, setSaving] = useState(false);
+
+ const loadOrganizers = async () => {
+ try {
+ const data = await api.getOrganizers();
+ setOrganizers(data);
+ } catch (err: any) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadOrganizers();
+ }, []);
+
+ const openCreate = () => {
+ setForm({ name: "", slug: "" });
+ setEditingId(null);
+ setShowForm(true);
+ };
+
+ const openEdit = (org: any) => {
+ setForm({ name: org.name, slug: org.slug });
+ setEditingId(org.id);
+ setShowForm(true);
+ };
+
+ const handleNameChange = (name: string) => {
+ setForm({ name, slug: editingId ? form.slug : slugify(name) });
+ };
+
+ const handleSave = async () => {
+ if (!form.name.trim() || !form.slug.trim()) return;
+ setSaving(true);
+ setError("");
+ try {
+ if (editingId) {
+ await api.updateOrganizer(editingId, form);
+ } else {
+ await api.createOrganizer(form);
+ }
+ setShowForm(false);
+ setEditingId(null);
+ await loadOrganizers();
+ } catch (err: any) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm("Delete this organizer?")) return;
+ try {
+ await api.deleteOrganizer(id);
+ await loadOrganizers();
+ } catch (err: any) {
+ setError(err.message);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
Loading organizers...
+
+ );
+ }
+
+ return (
+
+
+
Organizers
+
+
+ Add Organizer
+
+
+
+
+ Organizers appear on public event cards and detail pages. The default is Belgian Bitcoin Embassy;
+ add other Belgian meetup groups so their events can be listed on this site.
+
+
+ {error &&
{error}
}
+
+ {showForm && (
+
+
+
+ {editingId ? "Edit Organizer" : "New Organizer"}
+
+ setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
+
+
+
+
+ handleNameChange(e.target.value)}
+ className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
+ />
+ setForm({ ...form, slug: e.target.value })}
+ className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
+ />
+
+
+
+ {saving ? "Saving..." : "Save"}
+
+ setShowForm(false)}
+ className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
+ >
+ Cancel
+
+
+
+ )}
+
+
+ {organizers.length === 0 ? (
+
No organizers found.
+ ) : (
+ organizers.map((org) => (
+
+
+
{org.name}
+
/events/organizer/{org.slug}
+
+
+
openEdit(org)}
+ className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
+ >
+
+
+
handleDelete(org.id)}
+ className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
+ >
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/overview/page.tsx b/frontend/app/admin/overview/page.tsx
index aeb2e11..09f45c1 100644
--- a/frontend/app/admin/overview/page.tsx
+++ b/frontend/app/admin/overview/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
-import { getMeetupStartUtc } from "@/lib/meetupEventTime";
+import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
@@ -94,7 +94,7 @@ export default function OverviewPage() {
Next Upcoming Meetup
{upcomingMeetup.title}
- {formatDate(upcomingMeetup.date)} · {upcomingMeetup.location}
+ {formatMeetupCivilDateLong(upcomingMeetup.date)} · {upcomingMeetup.location}
)}
diff --git a/frontend/app/contact/page.tsx b/frontend/app/contact/page.tsx
index b37694e..651d2b3 100644
--- a/frontend/app/contact/page.tsx
+++ b/frontend/app/contact/page.tsx
@@ -1,8 +1,7 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
-import { Send, Zap, ExternalLink } from "lucide-react";
+import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
export const metadata: Metadata = {
title: "Contact Us",
@@ -28,56 +27,7 @@ export default function ContactPage() {
decentralized community — there is no central office or email inbox.
-
+
diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx
index 330eecf..abd4278 100644
--- a/frontend/app/dashboard/layout.tsx
+++ b/frontend/app/dashboard/layout.tsx
@@ -16,9 +16,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
router.push("/login");
return;
}
- if (user.role === "ADMIN" || user.role === "MODERATOR") {
- router.push("/admin/overview");
- }
}, [user, loading, router]);
if (loading) {
@@ -33,7 +30,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
- if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
+ if (!user) {
return null;
}
diff --git a/frontend/app/events/[id]/EventDetailClient.tsx b/frontend/app/events/[id]/EventDetailClient.tsx
index ae2509d..c69ca28 100644
--- a/frontend/app/events/[id]/EventDetailClient.tsx
+++ b/frontend/app/events/[id]/EventDetailClient.tsx
@@ -2,25 +2,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink } from "lucide-react";
+import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink, Building2 } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
-
-function formatFullDate(dateStr: string) {
- const d = new Date(dateStr);
- return d.toLocaleString("en-US", {
- weekday: "long",
- year: "numeric",
- month: "long",
- day: "numeric",
- });
-}
+import { UpcomingEventsCarousel } from "@/components/public/UpcomingEventsCarousel";
+import { formatMeetupCivilDate, formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
function DateBadge({ dateStr }: { dateStr: string }) {
- const d = new Date(dateStr);
- const month = d.toLocaleString("en-US", { month: "short" }).toUpperCase();
- const day = String(d.getDate());
+ const civil = formatMeetupCivilDate(dateStr);
+ const month = civil?.monthShort ?? "—";
+ const day = civil?.day ?? "--";
return (
@@ -61,7 +53,12 @@ export default function EventDetailClient({ id }: { id: string }) {
.finally(() => setLoading(false));
}, [id]);
- const isPast = meetup ? new Date(meetup.date) < new Date() : false;
+ const isPast = meetup
+ ? (() => {
+ const start = getMeetupStartUtc(meetup.date, meetup.time || "00:00");
+ return !Number.isNaN(start.getTime()) && start < new Date();
+ })()
+ : false;
return (
<>
@@ -115,10 +112,35 @@ export default function EventDetailClient({ id }: { id: string }) {
+
+ {meetup.organizer?.slug ? (
+
+
+
+ Organized by{" "}
+
+ {meetup.organizer.name || "Belgian Bitcoin Embassy"}
+
+
+
+ ) : (
+
+
+
+ Organized by{" "}
+ {meetup.organizer?.name || "Belgian Bitcoin Embassy"}
+
+
+ )}
+
+
- {formatFullDate(meetup.date)}
+ {formatMeetupCivilDateLong(meetup.date)}
{meetup.time && (
@@ -152,6 +174,8 @@ export default function EventDetailClient({ id }: { id: string }) {
Register for this event
)}
+
+
>
)}
diff --git a/frontend/app/events/[id]/page.tsx b/frontend/app/events/[id]/page.tsx
index 1e51f84..416cf5c 100644
--- a/frontend/app/events/[id]/page.tsx
+++ b/frontend/app/events/[id]/page.tsx
@@ -26,9 +26,10 @@ export async function generateMetadata({ params }: Props): Promise
{
return { title: "Event Not Found" };
}
+ const orgLabel = event.organizer?.name || "Belgian Bitcoin Embassy";
const description =
event.description?.slice(0, 160) ||
- `Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by the Belgian Bitcoin Embassy.`;
+ `Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by ${orgLabel}.`;
const ogImage = event.imageId
? `/media/${event.imageId}`
@@ -69,6 +70,12 @@ export default async function EventDetailPage({ params }: Props) {
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
+ organizerName={event.organizer?.name}
+ organizerUrl={
+ event.organizer?.slug
+ ? `${siteUrl}/events/organizer/${event.organizer.slug}`
+ : undefined
+ }
/>
+
+
+ );
+}
+
+export default function OrganizerEventsClient({
+ slug,
+ organizerName,
+}: {
+ slug: string;
+ organizerName: string;
+}) {
+ const [meetups, setMeetups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ api
+ .getMeetups({ organizerSlug: slug })
+ .then((data: any) => {
+ const list = Array.isArray(data) ? data : [];
+ setMeetups(list);
+ })
+ .catch((err) => setError(err.message))
+ .finally(() => setLoading(false));
+ }, [slug]);
+
+ const now = new Date();
+ const upcoming = meetups.filter((m) => {
+ const start = getMeetupStartUtc(m.date, m.time || "00:00");
+ if (Number.isNaN(start.getTime())) return false;
+ return start >= now;
+ });
+ const past = meetups
+ .filter((m) => {
+ const start = getMeetupStartUtc(m.date, m.time || "00:00");
+ if (Number.isNaN(start.getTime())) return false;
+ return start < now;
+ })
+ .reverse();
+
+ return (
+ <>
+
+
+
+
+
+ {error && (
+
+ Failed to load events: {error}
+
+ )}
+
+
+
+
+ Upcoming
+ {!loading && upcoming.length > 0 && (
+
+ {upcoming.length}
+
+ )}
+
+
+
+
+ {loading ? (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ ) : upcoming.length === 0 ? (
+
+
+ No upcoming events from this organizer.
+
+
+ ) : (
+
+ {upcoming.map((m) => (
+
+ ))}
+
+ )}
+
+
+ {(loading || past.length > 0) && (
+
+
Past events
+ {loading ? (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ ) : (
+
+ {past.map((m) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/events/organizer/[slug]/page.tsx b/frontend/app/events/organizer/[slug]/page.tsx
new file mode 100644
index 0000000..00ab563
--- /dev/null
+++ b/frontend/app/events/organizer/[slug]/page.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import OrganizerEventsClient from "./OrganizerEventsClient";
+import { apiUrl } from "@/lib/api-base";
+
+async function fetchOrganizer(slug: string) {
+ try {
+ const res = await fetch(apiUrl(`/organizers/by-slug/${encodeURIComponent(slug)}`), {
+ next: { revalidate: 300 },
+ });
+ if (!res.ok) return null;
+ return res.json() as Promise<{ id: string; name: string; slug: string }>;
+ } catch {
+ return null;
+ }
+}
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { slug } = await params;
+ const org = await fetchOrganizer(slug);
+ if (!org) {
+ return { title: "Organizer not found" };
+ }
+ return {
+ title: `Events by ${org.name}`,
+ description: `Upcoming and past Bitcoin events organized by ${org.name} in Belgium.`,
+ alternates: { canonical: `/events/organizer/${slug}` },
+ };
+}
+
+export default async function OrganizerArchivePage({ params }: Props) {
+ const { slug } = await params;
+ const org = await fetchOrganizer(slug);
+ if (!org) {
+ notFound();
+ }
+ return ;
+}
diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx
index f854d70..fd34563 100644
--- a/frontend/app/events/page.tsx
+++ b/frontend/app/events/page.tsx
@@ -1,80 +1,12 @@
"use client";
import { useEffect, useState } from "react";
-import Link from "next/link";
-import { MapPin, Clock, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
-
-function formatMeetupDate(dateStr: string) {
- const d = new Date(dateStr);
- return {
- month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
- day: String(d.getDate()),
- full: d.toLocaleString("en-US", {
- weekday: "long",
- month: "long",
- day: "numeric",
- year: "numeric",
- }),
- };
-}
-
-function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
- const { month, day, full } = formatMeetupDate(meetup.date);
- return (
-
-
-
-
- {month}
-
- {day}
-
-
-
- {meetup.title}
-
-
{full}
-
-
-
- {meetup.description && (
-
- {meetup.description}
-
- )}
-
-
- {meetup.location && (
-
-
- {meetup.location}
-
- )}
- {meetup.time && (
-
-
- {meetup.time}
-
- )}
-
-
-
- View Details
-
-
- );
-}
+import { MeetupCard } from "@/components/public/MeetupCard";
+import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
function CardSkeleton() {
return (
@@ -150,14 +82,17 @@ export default function EventsPage() {
)}
-
- Upcoming
- {!loading && upcoming.length > 0 && (
-
- {upcoming.length}
-
- )}
-
+
+
+ Upcoming
+ {!loading && upcoming.length > 0 && (
+
+ {upcoming.length}
+
+ )}
+
+
+
{loading ? (
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 3f81299..f93a1f2 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,10 +1,21 @@
import type { Metadata, Viewport } from "next";
+import Script from "next/script";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
+const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN?.trim();
+const plausibleAnalyticsOrigin = process.env.NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN?.trim().replace(
+ /\/$/,
+ "",
+);
+const plausibleScriptSrc =
+ plausibleDomain && plausibleAnalyticsOrigin
+ ? `${plausibleAnalyticsOrigin}/js/script.js`
+ : null;
+
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
@@ -83,6 +94,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
+ {plausibleScriptSrc && plausibleDomain ? (
+
+ ) : null}
{children}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 0738a10..3f7ac16 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { Navbar } from "@/components/public/Navbar";
import { HeroSection } from "@/components/public/HeroSection";
-import { KnowledgeCards } from "@/components/public/KnowledgeCards";
import { AboutSection } from "@/components/public/AboutSection";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
import { MeetupsSection } from "@/components/public/MeetupsSection";
@@ -11,6 +10,7 @@ import { FAQSection } from "@/components/public/FAQSection";
import { FinalCTASection } from "@/components/public/FinalCTASection";
import { Footer } from "@/components/public/Footer";
import { api } from "@/lib/api";
+import { formatMeetupCivilDate, getMeetupStartUtc } from "@/lib/meetupEventTime";
export default function HomePage() {
const [meetup, setMeetup] = useState
(null);
@@ -22,10 +22,18 @@ export default function HomePage() {
.then((data: any) => {
const all = Array.isArray(data) ? data : data?.meetups ?? [];
const now = new Date();
- // Keep only PUBLISHED events with a future date, sorted closest-first
+ // Keep only PUBLISHED events with a future start (Brussels wall time → UTC), sorted closest-first
const upcoming = all
- .filter((m: any) => m.status === "PUBLISHED" && m.date && new Date(m.date) > now)
- .sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime());
+ .filter((m: any) => {
+ if (m.status !== "PUBLISHED" || !m.date) return false;
+ const start = getMeetupStartUtc(m.date, m.time || "00:00");
+ return !Number.isNaN(start.getTime()) && start > now;
+ })
+ .sort(
+ (a: any, b: any) =>
+ getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
+ getMeetupStartUtc(b.date, b.time || "00:00").getTime()
+ );
setAllMeetups(upcoming);
if (upcoming.length > 0) setMeetup(upcoming[0]);
})
@@ -36,11 +44,14 @@ export default function HomePage() {
.catch(() => {});
}, []);
+ const featuredCivil = meetup ? formatMeetupCivilDate(meetup.date) : null;
const meetupProps = meetup
? {
id: meetup.id,
- month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
- day: String(new Date(meetup.date).getDate()),
+ month: featuredCivil
+ ? featuredCivil.monthShort.charAt(0) + featuredCivil.monthShort.slice(1).toLowerCase()
+ : "TBD",
+ day: featuredCivil?.day ?? "--",
title: meetup.title,
location: meetup.location,
time: meetup.time,
@@ -57,7 +68,6 @@ export default function HomePage() {
-
diff --git a/frontend/app/privacy/page.tsx b/frontend/app/privacy/page.tsx
index af96a55..308e424 100644
--- a/frontend/app/privacy/page.tsx
+++ b/frontend/app/privacy/page.tsx
@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Privacy Policy",
description:
- "Privacy policy for the Belgian Bitcoin Embassy website. We collect minimal data, use no tracking cookies, and respect your sovereignty.",
+ "GDPR-oriented privacy policy for the Belgian Bitcoin Embassy. Learn what data we process, why we process it, and your rights.",
openGraph: {
title: "Privacy Policy - Belgian Bitcoin Embassy",
- description: "How we handle your data. Minimal collection, no tracking, full transparency.",
+ description:
+ "How we process data for account access, moderation, and community features, with clear GDPR rights and transparency.",
},
alternates: { canonical: "/privacy" },
};
@@ -20,43 +21,93 @@ export default function PrivacyPage() {
-
Privacy Policy
+
Privacy Policy
+
Last updated: April 3, 2026
- Overview
+ Who We Are
- The Belgian Bitcoin Embassy values your privacy. This website is designed
- to collect as little personal data as possible. We do not use tracking
- cookies, analytics services, or advertising networks.
+ Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education
+ and meetups in Belgium. We aim to process the minimum data needed to run this
+ website safely and reliably.
- Data We Collect
+ What Data We Process
- If you log in using a Nostr extension, we store your public key to
- identify your session. Public keys are, by nature, public information
- on the Nostr network. We do not collect email addresses, names, or
- any other personal identifiers.
+ If you log in with Nostr, we process your public key, role, and optional
+ username. We also process content needed to operate the site, such as posts,
+ submissions, media metadata, and moderation records. Some Nostr-related data
+ may be cached on our servers to improve performance.
- Nostr Interactions
+ Why We Process Data
- Likes and comments are published to the Nostr network via your own
- extension. These are peer-to-peer actions and are not stored on our
- servers beyond local caching for display purposes.
+ We process data to provide core site features, maintain account sessions,
+ prevent abuse, moderate community interactions, and keep the service secure.
+ Our legal bases are contract (or steps requested by you before using features)
+ and legitimate interests (security, integrity, and service operation).
- Local Storage
+ Cookies and Local Storage
- We use browser local storage to persist your authentication session.
- You can clear this at any time by logging out or clearing your
- browser data.
+ We currently do not use third-party analytics or advertising cookies. We do use
+ browser local storage to keep your authentication session active. You can clear
+ this data at any time by logging out or clearing browser storage.
+
+
+
+
+ Recipients
+
+ When you interact through Nostr, your actions are published on the Nostr network,
+ which is public by design. We may also use infrastructure providers to host and
+ secure the website.
+
+
+
+
+ Retention
+
+ We keep account and operational data only as long as needed for service operation,
+ security, and moderation. Technical logs may be retained for a limited period.
+ You can remove local browser data at any time.
+
+
+
+
+ Your GDPR Rights
+
+ Depending on applicable law, you may have rights to access, rectify, erase, restrict,
+ object to, or request portability of your personal data. You also have the right to
+ lodge a complaint with the Belgian Data Protection Authority.
+
+
+
+
+ International Transfers
+
+ If technical providers process data outside the EEA, we aim to rely on appropriate
+ safeguards as required under GDPR.
+
+
+
+
+ Children
+ This website is not directed at children under the age of 16.
+
+
+
+ Policy Updates
+
+ We may update this Privacy Policy from time to time. Material changes are reflected
+ by updating the date at the top of this page.
diff --git a/frontend/app/terms/page.tsx b/frontend/app/terms/page.tsx
index fd44cea..c8d82c4 100644
--- a/frontend/app/terms/page.tsx
+++ b/frontend/app/terms/page.tsx
@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Terms of Use",
description:
- "Terms of use for the Belgian Bitcoin Embassy website. Community-driven, non-commercial Bitcoin education platform in Belgium.",
+ "Terms of use for the Belgian Bitcoin Embassy website, including education-only scope, risk warnings, and user responsibilities.",
openGraph: {
title: "Terms of Use - Belgian Bitcoin Embassy",
- description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
+ description:
+ "Terms governing access, risk disclosures, no-investment-advice scope, and liability limits for the Belgian Bitcoin Embassy platform.",
},
alternates: { canonical: "/terms" },
};
@@ -20,34 +21,56 @@ export default function TermsPage() {
-
Terms of Use
+
Terms of Use
+
Last updated: April 3, 2026
- About This Site
+ Acceptance and Changes
- The Belgian Bitcoin Embassy website is a community-driven, non-commercial
- platform focused on Bitcoin education and meetups in Belgium. By using
- this site, you agree to these terms.
+ By accessing or using this website, you agree to these Terms of Use. We may
+ update these terms from time to time, and continued use after updates means you
+ accept the revised terms.
- Content
+ Nature of the Service
- Blog content on this site is curated from the Nostr network. The
- Belgian Bitcoin Embassy does not claim ownership of third-party
- content and provides it for educational purposes only. Content
- moderation is applied locally and does not affect the Nostr network.
+ This website provides general Bitcoin education and community information.
+ Nothing on this website is financial, investment, legal, or tax advice.
+ We do not make recommendations to buy, sell, or hold Bitcoin or any other
+ crypto-asset. Content is general in nature and not tailored to your personal
+ circumstances.
- No Financial Advice
+ Crypto Risk Warning
- Nothing on this website constitutes financial advice. Bitcoin is a
- volatile asset. Always do your own research and consult qualified
- professionals before making financial decisions.
+ Crypto-assets are highly volatile and you can lose all of your money.
+ Crypto-assets are not regulated in the same way as traditional financial
+ products. Regulatory rules may change, and availability may differ by
+ jurisdiction. Always do your own research and consult a qualified professional
+ before making financial decisions.
+
+
+
+
+ MiCA and Regulatory Position
+
+ Belgian Bitcoin Embassy presents this website as an educational platform and not
+ as a crypto-asset service provider. If the nature of our activities changes, we
+ may update these terms and related legal pages.
+
+
+
+
+ Content and Third Parties
+
+ Some content is curated from the Nostr network. We do not claim ownership of
+ third-party content. Local moderation may hide or limit content on this site,
+ but does not change content on the Nostr network itself.
@@ -61,11 +84,48 @@ export default function TermsPage() {
- Liability
+ Paid and Commercial Features
- The Belgian Bitcoin Embassy is a community initiative, not a legal
- entity. We provide this platform as-is with no warranties. Use at
- your own discretion.
+ Certain features may involve Lightning payments, such as paid public board
+ messages. Any such feature is optional and does not change the educational
+ nature of the site.
+
+
+
+
+ Affiliate and Sponsorship Transparency
+
+ As of the last updated date above, we do not earn referral fees from links on
+ this website. If sponsored or affiliate content is added in the future, it will
+ be clearly disclosed.
+
+
+
+
+ Disclaimer and Liability
+
+ This platform is provided on an "as is" and "as available" basis without
+ warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin
+ Embassy is not liable for losses or damages resulting from your use of this site
+ or reliance on its content.
+
+
+
+
+ Governing Law
+
+ These terms are governed by Belgian law, without prejudice to mandatory consumer
+ protections that apply in your jurisdiction.
+
+
+
+
+ Contact
+
+ For terms-related questions, contact us through our{" "}
+
+ community channels
+ .
diff --git a/frontend/components/admin/AdminSidebar.tsx b/frontend/components/admin/AdminSidebar.tsx
index b1135b8..31166cd 100644
--- a/frontend/components/admin/AdminSidebar.tsx
+++ b/frontend/components/admin/AdminSidebar.tsx
@@ -20,11 +20,13 @@ import {
ImageIcon,
HelpCircle,
MessageSquare,
+ Building2,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
+ { href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
diff --git a/frontend/components/public/AddToCalendarDialog.tsx b/frontend/components/public/AddToCalendarDialog.tsx
new file mode 100644
index 0000000..4980af7
--- /dev/null
+++ b/frontend/components/public/AddToCalendarDialog.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
+
+const siteUrl =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
+
+function Dialog({ onClose }: { onClose: () => void }) {
+ const icsUrl = `${siteUrl}/calendar.ics`;
+ const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(icsUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
Add to Calendar
+
+
+
+ Subscribe to this feed to get all public Belgian Bitcoin Embassy
+ meetups in your calendar. New events are added automatically.
+
+
+
+
+
+ Calendar URL
+
+
+ e.currentTarget.select()}
+ />
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function AddToCalendarButton({ className }: { className?: string }) {
+ const [open, setOpen] = useState(false);
+ const close = useCallback(() => setOpen(false), []);
+
+ return (
+ <>
+
setOpen(true)}
+ title="Subscribe to get all future meetups automatically"
+ className={
+ className ??
+ "flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
+ }
+ >
+
+ Add to Calendar
+
+ {open &&
}
+ >
+ );
+}
diff --git a/frontend/components/public/ContactChannelGrid.tsx b/frontend/components/public/ContactChannelGrid.tsx
new file mode 100644
index 0000000..381de7a
--- /dev/null
+++ b/frontend/components/public/ContactChannelGrid.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Send, Zap, ExternalLink } from "lucide-react";
+import { api } from "@/lib/api";
+
+const CHANNELS = [
+ {
+ key: "telegram_link" as const,
+ title: "Telegram",
+ description:
+ "Join our Telegram group for quick questions and community chat.",
+ Icon: Send,
+ },
+ {
+ key: "nostr_link" as const,
+ title: "Nostr",
+ description: "Follow us on Nostr for censorship-resistant communication.",
+ Icon: Zap,
+ },
+ {
+ key: "x_link" as const,
+ title: "X (Twitter)",
+ description: "Follow us on X for announcements and updates.",
+ Icon: ExternalLink,
+ },
+];
+
+export function ContactChannelGrid() {
+ const [settings, setSettings] = useState
>({});
+
+ useEffect(() => {
+ api
+ .getPublicSettings()
+ .then((data) => setSettings(data))
+ .catch(() => {});
+ }, []);
+
+ return (
+
+ {CHANNELS.map(({ key, title, description, Icon }) => {
+ const href = settings[key] || "#";
+ const isExternal = href.startsWith("http");
+ return (
+
+
+ {title}
+ {description}
+
+ );
+ })}
+
+
+
Meetups
+
+ The best way to connect is in person. Come to our monthly meetup in
+ Brussels.
+
+
+ See next meetup →
+
+
+
+ );
+}
diff --git a/frontend/components/public/Footer.tsx b/frontend/components/public/Footer.tsx
index 788c1c8..dd0ba24 100644
--- a/frontend/components/public/Footer.tsx
+++ b/frontend/components/public/Footer.tsx
@@ -37,6 +37,23 @@ export function Footer() {
© Belgian Bitcoin Embassy.
+
+
+
Disclaimer
+
+ The Belgian Bitcoin Embassy provides information for educational purposes only and
+ does not offer financial, investment, or legal advice. Bitcoin and other
+ cryptocurrencies are subject to high volatility and potential risks. Always conduct
+ your own research and consult a qualified professional before making any financial
+ decisions. The Embassy is not responsible for any losses or damages resulting from the
+ use of this information.
+
+
+ Cryptocurrencies may be subject to regulatory changes; ensure compliance with local
+ laws. Remember to practice safe security measures, including the use of secure wallets
+ and private key protection.
+
+
);
diff --git a/frontend/components/public/JsonLd.tsx b/frontend/components/public/JsonLd.tsx
index 17ce4e7..cceb275 100644
--- a/frontend/components/public/JsonLd.tsx
+++ b/frontend/components/public/JsonLd.tsx
@@ -106,6 +106,8 @@ interface EventJsonLdProps {
location?: string;
url: string;
imageUrl?: string;
+ organizerName?: string;
+ organizerUrl?: string;
}
export function EventJsonLd({
@@ -115,7 +117,11 @@ export function EventJsonLd({
location,
url,
imageUrl,
+ organizerName,
+ organizerUrl,
}: EventJsonLdProps) {
+ const orgName = organizerName || "Belgian Bitcoin Embassy";
+ const orgUrl = organizerUrl || siteUrl;
return (
-
-
- {CARDS.map((card) => (
-
-
-
-
-
-
{card.title}
-
- {card.description}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/components/public/MeetupCard.tsx b/frontend/components/public/MeetupCard.tsx
new file mode 100644
index 0000000..82b9985
--- /dev/null
+++ b/frontend/components/public/MeetupCard.tsx
@@ -0,0 +1,67 @@
+import Link from "next/link";
+import { MapPin, Clock, ArrowRight } from "lucide-react";
+import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
+
+export function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
+ const civil = formatMeetupCivilDate(meetup.date);
+ const month = civil?.monthShort ?? "—";
+ const day = civil?.day ?? "--";
+ const full = civil?.full ?? "";
+ return (
+
+
+
+
+ {month}
+
+ {day}
+
+
+
+ {meetup.title}
+
+
{full}
+
+
+
+ {meetup.description && (
+
+ {meetup.description}
+
+ )}
+
+
+ Organized by{" "}
+
+ {meetup.organizer?.name || "Belgian Bitcoin Embassy"}
+
+
+
+
+ {meetup.location && (
+
+
+ {meetup.location}
+
+ )}
+ {meetup.time && (
+
+
+ {meetup.time}
+
+ )}
+
+
+
+ View Details
+
+
+ );
+}
diff --git a/frontend/components/public/MeetupsSection.tsx b/frontend/components/public/MeetupsSection.tsx
index 8d37b27..83d19c8 100644
--- a/frontend/components/public/MeetupsSection.tsx
+++ b/frontend/components/public/MeetupsSection.tsx
@@ -1,5 +1,7 @@
-import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
+import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
+import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
+import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
interface MeetupData {
id?: string;
@@ -9,21 +11,13 @@ interface MeetupData {
location?: string;
link?: string;
description?: string;
+ organizer?: { name: string; slug?: string };
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
-function formatMeetupDate(dateStr: string) {
- const d = new Date(dateStr);
- return {
- month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
- day: String(d.getDate()),
- full: d.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
- };
-}
-
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
return (
@@ -36,14 +30,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
Upcoming Meetups
-
-
- Add to Calendar
-
+
{meetups.map((meetup, i) => {
- const { month, day, full } = formatMeetupDate(meetup.date);
+ const civil = formatMeetupCivilDate(meetup.date);
+ const month = civil?.monthShort ?? "—";
+ const day = civil?.day ?? "--";
+ const full = civil?.full ?? "";
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
@@ -92,6 +82,13 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
)}
+
+ Organized by{" "}
+
+ {meetup.organizer?.name || "Belgian Bitcoin Embassy"}
+
+
+
{meetup.location && (
@@ -123,13 +120,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
>
All events
-
-
- Add to Calendar
-
+
diff --git a/frontend/components/public/UpcomingEventsCarousel.tsx b/frontend/components/public/UpcomingEventsCarousel.tsx
new file mode 100644
index 0000000..6cf7d8e
--- /dev/null
+++ b/frontend/components/public/UpcomingEventsCarousel.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { api } from "@/lib/api";
+import { getMeetupStartUtc } from "@/lib/meetupEventTime";
+import { MeetupCard } from "@/components/public/MeetupCard";
+
+export function UpcomingEventsCarousel({ excludeId }: { excludeId: string }) {
+ const [items, setItems] = useState
([]);
+ const scrollerRef = useRef(null);
+
+ useEffect(() => {
+ api
+ .getMeetups()
+ .then((data: any) => {
+ const list = Array.isArray(data) ? data : [];
+ const now = new Date();
+ const upcoming = list
+ .filter((m: any) => {
+ if (m.id === excludeId) return false;
+ const start = getMeetupStartUtc(m.date, m.time || "00:00");
+ if (Number.isNaN(start.getTime())) return false;
+ return start >= now;
+ })
+ .sort(
+ (a: any, b: any) =>
+ getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
+ getMeetupStartUtc(b.date, b.time || "00:00").getTime()
+ );
+ setItems(upcoming);
+ })
+ .catch(() => setItems([]));
+ }, [excludeId]);
+
+ const scrollByDir = (dir: -1 | 1) => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const delta = Math.min(el.clientWidth * 0.85, 360);
+ el.scrollBy({ left: dir * delta, behavior: "smooth" });
+ };
+
+ if (items.length === 0) return null;
+
+ return (
+
+
+
More upcoming events
+
+ scrollByDir(-1)}
+ className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
+ aria-label="Scroll left"
+ >
+
+
+ scrollByDir(1)}
+ className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
+ aria-label="Scroll right"
+ >
+
+
+
+
+
+ {items.map((m) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 0c2a609..e58b680 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -51,10 +51,11 @@ export const api = {
request(`/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(`/meetups${qs ? `?${qs}` : ""}`);
},
@@ -103,6 +104,17 @@ export const api = {
deleteCategory: (id: string) =>
request(`/categories/${id}`, { method: "DELETE" }),
+ // Organizers
+ getOrganizers: () => request("/organizers"),
+ getOrganizerBySlug: (slug: string) =>
+ request(`/organizers/by-slug/${encodeURIComponent(slug)}`),
+ createOrganizer: (data: { name: string; slug: string }) =>
+ request("/organizers", { method: "POST", body: JSON.stringify(data) }),
+ updateOrganizer: (id: string, data: { name?: string; slug?: string }) =>
+ request(`/organizers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
+ deleteOrganizer: (id: string) =>
+ request(`/organizers/${id}`, { method: "DELETE" }),
+
// Relays
getRelays: () => request("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
diff --git a/frontend/lib/meetupEventTime.ts b/frontend/lib/meetupEventTime.ts
index 73a5094..3fc31f2 100644
--- a/frontend/lib/meetupEventTime.ts
+++ b/frontend/lib/meetupEventTime.ts
@@ -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 ?? "—";
+}