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,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 (
<div className="bg-zinc-800 rounded-xl px-4 py-3 text-center shrink-0 min-w-[60px]">
<span className="block text-[11px] font-bold uppercase text-primary tracking-wider leading-none mb-1">
@@ -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 }) {
</div>
</div>
<div className="flex flex-wrap gap-4 mb-6 text-sm text-on-surface-variant">
{meetup.organizer?.slug ? (
<Link
href={`/events/organizer/${meetup.organizer.slug}`}
className="flex items-center gap-2 text-primary hover:underline underline-offset-4"
>
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
<span className="font-semibold">
{meetup.organizer.name || "Belgian Bitcoin Embassy"}
</span>
</span>
</Link>
) : (
<div className="flex items-center gap-2">
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 mb-10 text-sm text-on-surface-variant">
<div className="flex items-center gap-2">
<Calendar size={15} className="text-primary/70 shrink-0" />
{formatFullDate(meetup.date)}
{formatMeetupCivilDateLong(meetup.date)}
</div>
{meetup.time && (
<div className="flex items-center gap-2">
@@ -152,6 +174,8 @@ export default function EventDetailClient({ id }: { id: string }) {
Register for this event <ExternalLink size={16} />
</a>
)}
<UpcomingEventsCarousel excludeId={meetup.id} />
</>
)}
</div>

View File

@@ -26,9 +26,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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
}
/>
<BreadcrumbJsonLd
items={[