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

@@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
const siteUrl =
typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
function Dialog({ onClose }: { onClose: () => void }) {
const icsUrl = `${siteUrl}/calendar.ics`;
const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
const [copied, setCopied] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCopy = async () => {
await navigator.clipboard.writeText(icsUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-md p-6 shadow-2xl"
>
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-on-surface-variant/50 hover:text-on-surface transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
<div className="flex items-center gap-2.5 mb-4">
<div className="bg-primary/10 text-primary rounded-lg p-2">
<CalendarPlus size={20} />
</div>
<h2 className="text-lg font-bold">Add to Calendar</h2>
</div>
<p className="text-sm text-on-surface-variant leading-relaxed mb-6">
Subscribe to this feed to get all public Belgian Bitcoin Embassy
meetups in your calendar. New events are added automatically.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-on-surface-variant/60 mb-1.5">
Calendar URL
</label>
<div className="flex gap-2">
<input
readOnly
value={icsUrl}
className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-on-surface select-all focus:outline-none focus:border-primary/50"
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
onClick={handleCopy}
className="shrink-0 flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2 text-xs font-medium transition-all"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<a
href={webcalUrl}
className="flex items-center justify-center gap-1.5 bg-primary text-on-primary rounded-lg px-3 py-2.5 text-xs font-semibold hover:brightness-110 transition-all"
>
<ExternalLink size={14} />
Open in Calendar
</a>
<a
href="/calendar.ics"
download="bbe-events.ics"
className="flex items-center justify-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2.5 text-xs font-semibold transition-all"
>
<Download size={14} />
Download .ics
</a>
</div>
</div>
</div>
</div>
);
}
export function AddToCalendarButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="Subscribe to get all future meetups automatically"
className={
className ??
"flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
}
>
<CalendarPlus size={14} />
Add to Calendar
</button>
{open && <Dialog onClose={close} />}
</>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Send, Zap, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
const CHANNELS = [
{
key: "telegram_link" as const,
title: "Telegram",
description:
"Join our Telegram group for quick questions and community chat.",
Icon: Send,
},
{
key: "nostr_link" as const,
title: "Nostr",
description: "Follow us on Nostr for censorship-resistant communication.",
Icon: Zap,
},
{
key: "x_link" as const,
title: "X (Twitter)",
description: "Follow us on X for announcements and updates.",
Icon: ExternalLink,
},
];
export function ContactChannelGrid() {
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api
.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{CHANNELS.map(({ key, title, description, Icon }) => {
const href = settings[key] || "#";
const isExternal = href.startsWith("http");
return (
<a
key={key}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Icon size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">{title}</h2>
<p className="text-on-surface-variant text-sm">{description}</p>
</a>
);
})}
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup in
Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
);
}

View File

@@ -37,6 +37,23 @@ export function Footer() {
<p className="text-white opacity-50 text-xs sm:text-sm tracking-widest uppercase max-w-[min(100%,22rem)] sm:max-w-md leading-relaxed text-balance">
&copy; Belgian Bitcoin Embassy.
</p>
<div className="max-w-3xl text-white/65 text-xs sm:text-sm leading-relaxed text-left">
<h2 className="text-white/75 font-semibold mb-2">Disclaimer</h2>
<p>
The Belgian Bitcoin Embassy provides information for educational purposes only and
does not offer financial, investment, or legal advice. Bitcoin and other
cryptocurrencies are subject to high volatility and potential risks. Always conduct
your own research and consult a qualified professional before making any financial
decisions. The Embassy is not responsible for any losses or damages resulting from the
use of this information.
</p>
<p className="mt-3">
Cryptocurrencies may be subject to regulatory changes; ensure compliance with local
laws. Remember to practice safe security measures, including the use of secure wallets
and private key protection.
</p>
</div>
</div>
</footer>
);

View File

@@ -106,6 +106,8 @@ interface EventJsonLdProps {
location?: string;
url: string;
imageUrl?: string;
organizerName?: string;
organizerUrl?: string;
}
export function EventJsonLd({
@@ -115,7 +117,11 @@ export function EventJsonLd({
location,
url,
imageUrl,
organizerName,
organizerUrl,
}: EventJsonLdProps) {
const orgName = organizerName || "Belgian Bitcoin Embassy";
const orgUrl = organizerUrl || siteUrl;
return (
<JsonLd
data={{
@@ -141,8 +147,8 @@ export function EventJsonLd({
: {}),
organizer: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
name: orgName,
url: orgUrl,
},
image:
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,

View File

@@ -1,49 +0,0 @@
import { Landmark, Infinity, Key } from "lucide-react";
const CARDS = [
{
icon: Landmark,
title: "Money without banks",
description:
"Operate outside the legacy financial system with peer-to-peer digital sound money.",
},
{
icon: Infinity,
title: "Scarcity: 21 million",
description:
"A mathematical certainty of fixed supply. No inflation, no dilution, ever.",
},
{
icon: Key,
title: "Self-custody",
description:
"True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.",
},
];
export function KnowledgeCards() {
return (
<section className="py-16 px-8 border-t border-zinc-800/50">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{CARDS.map((card) => (
<div
key={card.title}
className="flex gap-4 p-6 rounded-xl bg-zinc-900/60 border border-zinc-800/60"
>
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<card.icon size={16} className="text-primary" />
</div>
<div>
<h4 className="font-bold mb-1.5 text-sm">{card.title}</h4>
<p className="text-on-surface-variant text-sm leading-relaxed">
{card.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
export function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
const civil = formatMeetupCivilDate(meetup.date);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
const full = civil?.full ?? "";
return (
<Link
href={`/events/${meetup.id}`}
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
muted
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
: "border-zinc-800 hover:border-zinc-700"
}`}
>
<div className="flex items-start gap-4 mb-4">
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
Organized by{" "}
<span className="text-on-surface-variant/70 normal-case tracking-normal">
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</p>
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.time}
</p>
)}
</div>
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
View Details <ArrowRight size={12} />
</span>
</Link>
);
}

View File

@@ -1,5 +1,7 @@
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
interface MeetupData {
id?: string;
@@ -9,21 +11,13 @@ interface MeetupData {
location?: string;
link?: string;
description?: string;
organizer?: { name: string; slug?: string };
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
};
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
@@ -36,14 +30,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
<h2 className="text-3xl font-black tracking-tight">Upcoming Meetups</h2>
</div>
<div className="hidden md:flex items-center gap-4">
<a
href="/calendar.ics"
title="Subscribe to get all future meetups automatically"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
<AddToCalendarButton />
<Link
href="/events"
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
@@ -62,7 +49,10 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{meetups.map((meetup, i) => {
const { month, day, full } = formatMeetupDate(meetup.date);
const civil = formatMeetupCivilDate(meetup.date);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
const full = civil?.full ?? "";
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
@@ -92,6 +82,13 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
</p>
)}
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
Organized by{" "}
<span className="text-on-surface-variant/70 normal-case tracking-normal">
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</p>
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
@@ -123,13 +120,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
>
All events <ArrowRight size={16} />
</Link>
<a
href="/calendar.ics"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
<AddToCalendarButton />
</div>
</div>
</section>

View File

@@ -0,0 +1,80 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { MeetupCard } from "@/components/public/MeetupCard";
export function UpcomingEventsCarousel({ excludeId }: { excludeId: string }) {
const [items, setItems] = useState<any[]>([]);
const scrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
const now = new Date();
const upcoming = list
.filter((m: any) => {
if (m.id === excludeId) return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
})
.sort(
(a: any, b: any) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
);
setItems(upcoming);
})
.catch(() => setItems([]));
}, [excludeId]);
const scrollByDir = (dir: -1 | 1) => {
const el = scrollerRef.current;
if (!el) return;
const delta = Math.min(el.clientWidth * 0.85, 360);
el.scrollBy({ left: dir * delta, behavior: "smooth" });
};
if (items.length === 0) return null;
return (
<section className="mt-16 pt-12 border-t border-zinc-800/50">
<div className="flex items-end justify-between gap-4 mb-6">
<h2 className="text-lg font-black tracking-tight">More upcoming events</h2>
<div className="flex gap-2 shrink-0">
<button
type="button"
onClick={() => scrollByDir(-1)}
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
aria-label="Scroll left"
>
<ChevronLeft size={18} />
</button>
<button
type="button"
onClick={() => scrollByDir(1)}
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
aria-label="Scroll right"
>
<ChevronRight size={18} />
</button>
</div>
</div>
<div
ref={scrollerRef}
className="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 -mx-1 px-1 [scrollbar-width:thin]"
>
{items.map((m) => (
<div key={m.id} className="snap-start shrink-0 w-[min(280px,calc(100vw-5rem))] sm:w-[280px]">
<MeetupCard meetup={m} />
</div>
))}
</div>
</section>
);
}