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

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

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

View File

@@ -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<Set<string>>(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() {
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="md:col-span-2 space-y-3">
<label className="text-on-surface/60 text-xs block">Organizer</label>
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<select
value={form.organizerId}
onChange={(e) => 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 ? (
<option value="">No organizers add one in Organizers</option>
) : (
organizers.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))
)}
</select>
<button
type="button"
onClick={() => 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"}
</button>
</div>
{showAddOrganizer && (
<div className="rounded-lg border border-surface-container-highest p-4 space-y-3">
<input
placeholder="Organizer name"
value={newOrgName}
onChange={(e) => {
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"
/>
<input
placeholder="URL slug"
value={newOrgSlug}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleCreateOrganizer}
disabled={savingOrganizer || !newOrgName.trim()}
className="px-4 py-2 rounded-lg bg-primary/20 text-primary font-semibold text-sm hover:bg-primary/30 disabled:opacity-50"
>
{savingOrganizer ? "Creating…" : "Create and select"}
</button>
</div>
)}
</div>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
External registration link{" "}
@@ -579,7 +683,12 @@ export default function EventsPage() {
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.title || !form.date}
disabled={
saving ||
!form.title ||
!form.date ||
(!editingId && !form.organizerId)
}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
@@ -761,13 +870,18 @@ export default function EventsPage() {
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
)}
</div>
{meetup.organizer?.name && (
<p className="text-on-surface/40 text-xs mb-1 truncate">
{meetup.organizer.name}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<StatusDropdown
meetup={meetup}
onChange={(v) => handlePatch(meetup.id, { status: v })}
/>
<span className="text-on-surface/50 text-xs">
{meetup.date ? formatDate(meetup.date) : "No date"}
{meetup.date ? formatMeetupCivilDateLong(meetup.date) : "No date"}
{meetup.location && ` · ${meetup.location}`}
</span>
</div>

View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<OrganizerForm>({ 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 (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading organizers...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Organizers</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add Organizer
</button>
</div>
<p className="text-on-surface/60 text-sm max-w-2xl">
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.
</p>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Organizer" : "New Organizer"}
</h2>
<button onClick={() => setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Display name"
value={form.name}
onChange={(e) => 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"
/>
<input
placeholder="URL slug (e.g. antwerp-bitcoin)"
value={form.slug}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => 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
</button>
</div>
</div>
)}
<div className="space-y-3">
{organizers.length === 0 ? (
<p className="text-on-surface/50 text-sm">No organizers found.</p>
) : (
organizers.map((org) => (
<div
key={org.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<h3 className="text-on-surface font-semibold">{org.name}</h3>
<p className="text-on-surface/50 text-sm">/events/organizer/{org.slug}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(org)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(org.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -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() {
<h2 className="text-lg font-semibold text-on-surface mb-3">Next Upcoming Meetup</h2>
<p className="text-primary font-semibold">{upcomingMeetup.title}</p>
<p className="text-on-surface/60 text-sm mt-1">
{formatDate(upcomingMeetup.date)} · {upcomingMeetup.location}
{formatMeetupCivilDateLong(upcomingMeetup.date)} · {upcomingMeetup.location}
</p>
</div>
)}