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:
@@ -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>
|
||||
|
||||
186
frontend/app/admin/organizers/page.tsx
Normal file
186
frontend/app/admin/organizers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user