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>