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>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { Send, Zap, ExternalLink } from "lucide-react";
|
||||
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Us",
|
||||
@@ -28,56 +27,7 @@ export default function ContactPage() {
|
||||
decentralized community — there is no central office or email inbox.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<a
|
||||
href="https://t.me/belgianbitcoinembassy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<Send size={28} className="text-primary mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Telegram</h2>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
Join our Telegram group for quick questions and community chat.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<Zap size={28} className="text-primary mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Nostr</h2>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
Follow us on Nostr for censorship-resistant communication.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<ExternalLink size={28} className="text-primary mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">X (Twitter)</h2>
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
Follow us on X for announcements and updates.
|
||||
</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>
|
||||
<ContactChannelGrid />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -16,9 +16,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
router.push("/admin/overview");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
@@ -33,7 +30,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
153
frontend/app/events/organizer/[slug]/OrganizerEventsClient.tsx
Normal file
153
frontend/app/events/organizer/[slug]/OrganizerEventsClient.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
||||
<div className="h-3 bg-zinc-800 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizerEventsClient({
|
||||
slug,
|
||||
organizerName,
|
||||
}: {
|
||||
slug: string;
|
||||
organizerName: string;
|
||||
}) {
|
||||
const [meetups, setMeetups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getMeetups({ organizerSlug: slug })
|
||||
.then((data: any) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMeetups(list);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = meetups.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start >= now;
|
||||
});
|
||||
const past = meetups
|
||||
.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start < now;
|
||||
})
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<header className="pt-24 pb-12 px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-6 text-sm font-medium"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
All events
|
||||
</Link>
|
||||
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
|
||||
Organizer
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">
|
||||
{organizerName}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant max-w-md leading-relaxed">
|
||||
Upcoming and past events hosted by this organizer.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
|
||||
{error && (
|
||||
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
|
||||
Failed to load events: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-black flex items-center gap-3">
|
||||
Upcoming
|
||||
{!loading && upcoming.length > 0 && (
|
||||
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
|
||||
{upcoming.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<AddToCalendarButton />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : upcoming.length === 0 ? (
|
||||
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
No upcoming events from this organizer.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{upcoming.map((m) => (
|
||||
<MeetupCard key={m.id} meetup={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(loading || past.length > 0) && (
|
||||
<div>
|
||||
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">Past events</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{past.map((m) => (
|
||||
<MeetupCard key={m.id} meetup={m} muted />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
frontend/app/events/organizer/[slug]/page.tsx
Normal file
42
frontend/app/events/organizer/[slug]/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import OrganizerEventsClient from "./OrganizerEventsClient";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
|
||||
async function fetchOrganizer(slug: string) {
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/organizers/by-slug/${encodeURIComponent(slug)}`), {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{ id: string; name: string; slug: string }>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const org = await fetchOrganizer(slug);
|
||||
if (!org) {
|
||||
return { title: "Organizer not found" };
|
||||
}
|
||||
return {
|
||||
title: `Events by ${org.name}`,
|
||||
description: `Upcoming and past Bitcoin events organized by ${org.name} in Belgium.`,
|
||||
alternates: { canonical: `/events/organizer/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OrganizerArchivePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const org = await fetchOrganizer(slug);
|
||||
if (!org) {
|
||||
notFound();
|
||||
}
|
||||
return <OrganizerEventsClient slug={slug} organizerName={org.name} />;
|
||||
}
|
||||
@@ -1,80 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
|
||||
const { month, day, full } = formatMeetupDate(meetup.date);
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
@@ -150,14 +82,17 @@ export default function EventsPage() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-black mb-8 flex items-center gap-3">
|
||||
Upcoming
|
||||
{!loading && upcoming.length > 0 && (
|
||||
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
|
||||
{upcoming.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-black flex items-center gap-3">
|
||||
Upcoming
|
||||
{!loading && upcoming.length > 0 && (
|
||||
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
|
||||
{upcoming.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<AddToCalendarButton />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { ClientProviders } from "@/components/providers/ClientProviders";
|
||||
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
|
||||
import "./globals.css";
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN?.trim();
|
||||
const plausibleAnalyticsOrigin = process.env.NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN?.trim().replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
const plausibleScriptSrc =
|
||||
plausibleDomain && plausibleAnalyticsOrigin
|
||||
? `${plausibleAnalyticsOrigin}/js/script.js`
|
||||
: null;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
@@ -83,6 +94,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" dir="ltr" className="dark">
|
||||
<body>
|
||||
{plausibleScriptSrc && plausibleDomain ? (
|
||||
<Script
|
||||
defer
|
||||
src={plausibleScriptSrc}
|
||||
data-domain={plausibleDomain}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
) : null}
|
||||
<OrganizationJsonLd />
|
||||
<WebSiteJsonLd />
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { HeroSection } from "@/components/public/HeroSection";
|
||||
import { KnowledgeCards } from "@/components/public/KnowledgeCards";
|
||||
import { AboutSection } from "@/components/public/AboutSection";
|
||||
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
|
||||
import { MeetupsSection } from "@/components/public/MeetupsSection";
|
||||
@@ -11,6 +10,7 @@ import { FAQSection } from "@/components/public/FAQSection";
|
||||
import { FinalCTASection } from "@/components/public/FinalCTASection";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatMeetupCivilDate, getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
|
||||
export default function HomePage() {
|
||||
const [meetup, setMeetup] = useState<any>(null);
|
||||
@@ -22,10 +22,18 @@ export default function HomePage() {
|
||||
.then((data: any) => {
|
||||
const all = Array.isArray(data) ? data : data?.meetups ?? [];
|
||||
const now = new Date();
|
||||
// Keep only PUBLISHED events with a future date, sorted closest-first
|
||||
// Keep only PUBLISHED events with a future start (Brussels wall time → UTC), sorted closest-first
|
||||
const upcoming = all
|
||||
.filter((m: any) => m.status === "PUBLISHED" && m.date && new Date(m.date) > now)
|
||||
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
.filter((m: any) => {
|
||||
if (m.status !== "PUBLISHED" || !m.date) return false;
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
return !Number.isNaN(start.getTime()) && start > now;
|
||||
})
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
|
||||
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
|
||||
);
|
||||
setAllMeetups(upcoming);
|
||||
if (upcoming.length > 0) setMeetup(upcoming[0]);
|
||||
})
|
||||
@@ -36,11 +44,14 @@ export default function HomePage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const featuredCivil = meetup ? formatMeetupCivilDate(meetup.date) : null;
|
||||
const meetupProps = meetup
|
||||
? {
|
||||
id: meetup.id,
|
||||
month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
|
||||
day: String(new Date(meetup.date).getDate()),
|
||||
month: featuredCivil
|
||||
? featuredCivil.monthShort.charAt(0) + featuredCivil.monthShort.slice(1).toLowerCase()
|
||||
: "TBD",
|
||||
day: featuredCivil?.day ?? "--",
|
||||
title: meetup.title,
|
||||
location: meetup.location,
|
||||
time: meetup.time,
|
||||
@@ -57,7 +68,6 @@ export default function HomePage() {
|
||||
<section id="about">
|
||||
<AboutSection />
|
||||
</section>
|
||||
<KnowledgeCards />
|
||||
<CommunityLinksSection settings={settings} />
|
||||
<section id="upcoming-meetups">
|
||||
<MeetupsSection meetups={allMeetups} />
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description:
|
||||
"Privacy policy for the Belgian Bitcoin Embassy website. We collect minimal data, use no tracking cookies, and respect your sovereignty.",
|
||||
"GDPR-oriented privacy policy for the Belgian Bitcoin Embassy. Learn what data we process, why we process it, and your rights.",
|
||||
openGraph: {
|
||||
title: "Privacy Policy - Belgian Bitcoin Embassy",
|
||||
description: "How we handle your data. Minimal collection, no tracking, full transparency.",
|
||||
description:
|
||||
"How we process data for account access, moderation, and community features, with clear GDPR rights and transparency.",
|
||||
},
|
||||
alternates: { canonical: "/privacy" },
|
||||
};
|
||||
@@ -20,43 +21,93 @@ export default function PrivacyPage() {
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
<h1 className="text-4xl font-black mb-8">Privacy Policy</h1>
|
||||
<h1 className="text-4xl font-black mb-3">Privacy Policy</h1>
|
||||
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
|
||||
|
||||
<div className="space-y-8 text-on-surface-variant leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Overview</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Who We Are</h2>
|
||||
<p>
|
||||
The Belgian Bitcoin Embassy values your privacy. This website is designed
|
||||
to collect as little personal data as possible. We do not use tracking
|
||||
cookies, analytics services, or advertising networks.
|
||||
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education
|
||||
and meetups in Belgium. We aim to process the minimum data needed to run this
|
||||
website safely and reliably.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Data We Collect</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">What Data We Process</h2>
|
||||
<p>
|
||||
If you log in using a Nostr extension, we store your public key to
|
||||
identify your session. Public keys are, by nature, public information
|
||||
on the Nostr network. We do not collect email addresses, names, or
|
||||
any other personal identifiers.
|
||||
If you log in with Nostr, we process your public key, role, and optional
|
||||
username. We also process content needed to operate the site, such as posts,
|
||||
submissions, media metadata, and moderation records. Some Nostr-related data
|
||||
may be cached on our servers to improve performance.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Nostr Interactions</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Why We Process Data</h2>
|
||||
<p>
|
||||
Likes and comments are published to the Nostr network via your own
|
||||
extension. These are peer-to-peer actions and are not stored on our
|
||||
servers beyond local caching for display purposes.
|
||||
We process data to provide core site features, maintain account sessions,
|
||||
prevent abuse, moderate community interactions, and keep the service secure.
|
||||
Our legal bases are contract (or steps requested by you before using features)
|
||||
and legitimate interests (security, integrity, and service operation).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Local Storage</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Cookies and Local Storage</h2>
|
||||
<p>
|
||||
We use browser local storage to persist your authentication session.
|
||||
You can clear this at any time by logging out or clearing your
|
||||
browser data.
|
||||
We currently do not use third-party analytics or advertising cookies. We do use
|
||||
browser local storage to keep your authentication session active. You can clear
|
||||
this data at any time by logging out or clearing browser storage.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Recipients</h2>
|
||||
<p>
|
||||
When you interact through Nostr, your actions are published on the Nostr network,
|
||||
which is public by design. We may also use infrastructure providers to host and
|
||||
secure the website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Retention</h2>
|
||||
<p>
|
||||
We keep account and operational data only as long as needed for service operation,
|
||||
security, and moderation. Technical logs may be retained for a limited period.
|
||||
You can remove local browser data at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Your GDPR Rights</h2>
|
||||
<p>
|
||||
Depending on applicable law, you may have rights to access, rectify, erase, restrict,
|
||||
object to, or request portability of your personal data. You also have the right to
|
||||
lodge a complaint with the Belgian Data Protection Authority.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">International Transfers</h2>
|
||||
<p>
|
||||
If technical providers process data outside the EEA, we aim to rely on appropriate
|
||||
safeguards as required under GDPR.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Children</h2>
|
||||
<p>This website is not directed at children under the age of 16.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Policy Updates</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. Material changes are reflected
|
||||
by updating the date at the top of this page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Use",
|
||||
description:
|
||||
"Terms of use for the Belgian Bitcoin Embassy website. Community-driven, non-commercial Bitcoin education platform in Belgium.",
|
||||
"Terms of use for the Belgian Bitcoin Embassy website, including education-only scope, risk warnings, and user responsibilities.",
|
||||
openGraph: {
|
||||
title: "Terms of Use - Belgian Bitcoin Embassy",
|
||||
description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
|
||||
description:
|
||||
"Terms governing access, risk disclosures, no-investment-advice scope, and liability limits for the Belgian Bitcoin Embassy platform.",
|
||||
},
|
||||
alternates: { canonical: "/terms" },
|
||||
};
|
||||
@@ -20,34 +21,56 @@ export default function TermsPage() {
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
<h1 className="text-4xl font-black mb-8">Terms of Use</h1>
|
||||
<h1 className="text-4xl font-black mb-3">Terms of Use</h1>
|
||||
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
|
||||
|
||||
<div className="space-y-8 text-on-surface-variant leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">About This Site</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Acceptance and Changes</h2>
|
||||
<p>
|
||||
The Belgian Bitcoin Embassy website is a community-driven, non-commercial
|
||||
platform focused on Bitcoin education and meetups in Belgium. By using
|
||||
this site, you agree to these terms.
|
||||
By accessing or using this website, you agree to these Terms of Use. We may
|
||||
update these terms from time to time, and continued use after updates means you
|
||||
accept the revised terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Content</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Nature of the Service</h2>
|
||||
<p>
|
||||
Blog content on this site is curated from the Nostr network. The
|
||||
Belgian Bitcoin Embassy does not claim ownership of third-party
|
||||
content and provides it for educational purposes only. Content
|
||||
moderation is applied locally and does not affect the Nostr network.
|
||||
This website provides general Bitcoin education and community information.
|
||||
Nothing on this website is financial, investment, legal, or tax advice.
|
||||
We do not make recommendations to buy, sell, or hold Bitcoin or any other
|
||||
crypto-asset. Content is general in nature and not tailored to your personal
|
||||
circumstances.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">No Financial Advice</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Crypto Risk Warning</h2>
|
||||
<p>
|
||||
Nothing on this website constitutes financial advice. Bitcoin is a
|
||||
volatile asset. Always do your own research and consult qualified
|
||||
professionals before making financial decisions.
|
||||
Crypto-assets are highly volatile and you can lose all of your money.
|
||||
Crypto-assets are not regulated in the same way as traditional financial
|
||||
products. Regulatory rules may change, and availability may differ by
|
||||
jurisdiction. Always do your own research and consult a qualified professional
|
||||
before making financial decisions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">MiCA and Regulatory Position</h2>
|
||||
<p>
|
||||
Belgian Bitcoin Embassy presents this website as an educational platform and not
|
||||
as a crypto-asset service provider. If the nature of our activities changes, we
|
||||
may update these terms and related legal pages.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Content and Third Parties</h2>
|
||||
<p>
|
||||
Some content is curated from the Nostr network. We do not claim ownership of
|
||||
third-party content. Local moderation may hide or limit content on this site,
|
||||
but does not change content on the Nostr network itself.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -61,11 +84,48 @@ export default function TermsPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Liability</h2>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Paid and Commercial Features</h2>
|
||||
<p>
|
||||
The Belgian Bitcoin Embassy is a community initiative, not a legal
|
||||
entity. We provide this platform as-is with no warranties. Use at
|
||||
your own discretion.
|
||||
Certain features may involve Lightning payments, such as paid public board
|
||||
messages. Any such feature is optional and does not change the educational
|
||||
nature of the site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Affiliate and Sponsorship Transparency</h2>
|
||||
<p>
|
||||
As of the last updated date above, we do not earn referral fees from links on
|
||||
this website. If sponsored or affiliate content is added in the future, it will
|
||||
be clearly disclosed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Disclaimer and Liability</h2>
|
||||
<p>
|
||||
This platform is provided on an "as is" and "as available" basis without
|
||||
warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin
|
||||
Embassy is not liable for losses or damages resulting from your use of this site
|
||||
or reliance on its content.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Governing Law</h2>
|
||||
<p>
|
||||
These terms are governed by Belgian law, without prejudice to mandatory consumer
|
||||
protections that apply in your jurisdiction.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-4">Contact</h2>
|
||||
<p>
|
||||
For terms-related questions, contact us through our{" "}
|
||||
<Link href="/#community" className="text-primary hover:underline">
|
||||
community channels
|
||||
</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user