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>
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
ImageIcon,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
|
||||
{ href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
|
||||
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
|
||||
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
|
||||
|
||||
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
126
frontend/components/public/AddToCalendarDialog.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
frontend/components/public/ContactChannelGrid.tsx
Normal file
75
frontend/components/public/ContactChannelGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© 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>
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
67
frontend/components/public/MeetupCard.tsx
Normal file
67
frontend/components/public/MeetupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal file
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -51,10 +51,11 @@ export const api = {
|
||||
request<void>(`/posts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Meetups
|
||||
getMeetups: (params?: { status?: string; admin?: boolean }) => {
|
||||
getMeetups: (params?: { status?: string; admin?: boolean; organizerSlug?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.admin) searchParams.set("admin", "true");
|
||||
if (params?.organizerSlug) searchParams.set("organizerSlug", params.organizerSlug);
|
||||
const qs = searchParams.toString();
|
||||
return request<any[]>(`/meetups${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
@@ -103,6 +104,17 @@ export const api = {
|
||||
deleteCategory: (id: string) =>
|
||||
request<void>(`/categories/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Organizers
|
||||
getOrganizers: () => request<any[]>("/organizers"),
|
||||
getOrganizerBySlug: (slug: string) =>
|
||||
request<any>(`/organizers/by-slug/${encodeURIComponent(slug)}`),
|
||||
createOrganizer: (data: { name: string; slug: string }) =>
|
||||
request<any>("/organizers", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateOrganizer: (id: string, data: { name?: string; slug?: string }) =>
|
||||
request<any>(`/organizers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteOrganizer: (id: string) =>
|
||||
request<void>(`/organizers/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Relays
|
||||
getRelays: () => request<any[]>("/relays"),
|
||||
addRelay: (data: { url: string; priority?: number }) =>
|
||||
|
||||
@@ -51,3 +51,44 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
|
||||
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
|
||||
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
|
||||
}
|
||||
|
||||
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
|
||||
|
||||
/**
|
||||
* Format the stored meetup calendar date (YYYY-MM-DD) for display without shifting
|
||||
* by the viewer's timezone. Date-only strings parsed as new Date("YYYY-MM-DD") are
|
||||
* UTC midnight and show the wrong local day in western zones.
|
||||
*/
|
||||
export function formatMeetupCivilDate(dateStr: string): {
|
||||
monthShort: string;
|
||||
day: string;
|
||||
full: string;
|
||||
} | null {
|
||||
const key = normalizeMeetupDateKey(dateStr);
|
||||
if (!key) return null;
|
||||
const parts = key.split("-").map(Number);
|
||||
const year = parts[0];
|
||||
const month = parts[1];
|
||||
const dayNum = parts[2];
|
||||
if (!year || !month || !dayNum) return null;
|
||||
|
||||
const ref = new Date(Date.UTC(year, month - 1, dayNum, 12, 0, 0));
|
||||
return {
|
||||
monthShort: ref
|
||||
.toLocaleString("en-US", { ...UTC_CAL_OPTS, month: "short" })
|
||||
.toUpperCase(),
|
||||
day: String(ref.getUTCDate()),
|
||||
full: ref.toLocaleString("en-US", {
|
||||
...UTC_CAL_OPTS,
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Long single-line civil date for event detail (same rules as formatMeetupCivilDate). */
|
||||
export function formatMeetupCivilDateLong(dateStr: string): string {
|
||||
return formatMeetupCivilDate(dateStr)?.full ?? "—";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user