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() { +
+ +
+ + +
+ {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" + /> + +
+ )} +
)} 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.

- +