feat: add SEO metadata, llms.txt, and markdown page mirrors

Centralize site metadata and social URLs for JSON-LD, expose llmstxt.org
content and per-page .md routes for LLM crawlers, and refactor blog/FAQ/events
pages with shared components.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-29 00:59:41 +02:00
co-authored by Cursor
parent 99380ef6aa
commit 6023991f5c
27 changed files with 1082 additions and 401 deletions
+10
View File
@@ -0,0 +1,10 @@
import { buildBlogMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildBlogMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+37 -252
View File
@@ -1,109 +1,42 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import {
BlogIndex,
type BlogPost,
type BlogCategory,
} from "@/components/public/BlogIndex";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface Post {
id: string;
slug: string;
title: string;
excerpt?: string;
content?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
const LIMIT = 9;
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
interface Category {
id: string;
name: string;
slug: string;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
function FeaturedPostSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse mb-12">
<div className="p-8 md:p-12 space-y-4">
<div className="h-5 w-24 bg-surface-container-high rounded-full" />
<div className="h-10 w-2/3 bg-surface-container-high rounded" />
<div className="space-y-2 max-w-2xl">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-1/2 bg-surface-container-high rounded" />
</div>
<div className="h-4 w-48 bg-surface-container-high rounded" />
</div>
</div>
);
}
export default function BlogPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const limit = 9;
useEffect(() => {
api.getCategories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
setPosts(data);
setTotal(t);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [activeCategory, page]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
export default async function BlogPage() {
const [{ posts, total }, categories] = await Promise.all([
fetchJson<{ posts: BlogPost[]; total: number }>(`/posts?page=1&limit=${LIMIT}`, {
posts: [],
total: 0,
}),
fetchJson<BlogCategory[]>("/categories", []),
]);
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
]}
/>
<Navbar />
<div className="min-h-screen">
@@ -121,160 +54,12 @@ export default function BlogPage() {
</div>
</header>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => { setActiveCategory("all"); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => { setActiveCategory(cat.slug); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<>
<FeaturedPostSkeleton />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
</>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((pc) => (
<span
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{pc.category.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
<BlogIndex
initialPosts={Array.isArray(posts) ? posts : []}
initialTotal={total ?? 0}
categories={Array.isArray(categories) ? categories : []}
limit={LIMIT}
/>
</div>
<Footer />
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Message Board — Pay with Lightning",
@@ -12,5 +13,15 @@ export const metadata: Metadata = {
};
export default function BoardLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Board", href: "/board" },
]}
/>
{children}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildCommunityMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildCommunityMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Community - Connect with Belgian Bitcoiners",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Community", href: "/community" },
]}
/>
{children}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildContactMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildContactMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Contact Us",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function ContactPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Contact", href: "/contact" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+10
View File
@@ -0,0 +1,10 @@
import { buildEventsMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildEventsMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+12 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import EventDetailClient from "./EventDetailClient";
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
import { getMeetupStartUtc, getMeetupEndUtc } from "@/lib/meetupEventTime";
async function fetchEvent(id: string) {
try {
@@ -59,6 +60,15 @@ export default async function EventDetailPage({ params }: Props) {
const event = await fetchEvent(id);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
let startDate = event?.date;
let endDate: string | undefined;
if (event?.date) {
const start = getMeetupStartUtc(event.date, event.time || "00:00");
if (!Number.isNaN(start.getTime())) startDate = start.toISOString();
const end = getMeetupEndUtc(event.date, event.time || "");
if (end && !Number.isNaN(end.getTime())) endDate = end.toISOString();
}
return (
<>
{event && (
@@ -66,7 +76,8 @@ export default async function EventDetailPage({ params }: Props) {
<EventJsonLd
name={event.title}
description={event.description}
startDate={event.date}
startDate={startDate}
endDate={endDate}
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Events - Bitcoin Meetups in Belgium",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function EventsLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Events", href: "/events" },
]}
/>
{children}
</>
);
}
+27 -60
View File
@@ -1,46 +1,26 @@
"use client";
import { useEffect, useState } from "react";
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 { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { apiUrl } from "@/lib/api-base";
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 className="space-y-2 mb-4">
<div className="h-3 bg-zinc-800 rounded w-full" />
<div className="h-3 bg-zinc-800 rounded w-5/6" />
</div>
</div>
);
// Re-render at most every 5 minutes so the upcoming/past split stays current.
export const revalidate = 300;
async function fetchMeetups(): Promise<any[]> {
try {
const res = await fetch(apiUrl("/meetups"), { next: { revalidate: 300 } });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export default function EventsPage() {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
export default async function EventsPage() {
const meetups = await fetchMeetups();
const now = new Date();
const upcoming = meetups.filter((m) => {
@@ -75,17 +55,11 @@ export default function EventsPage() {
</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 && (
{upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
@@ -94,11 +68,7 @@ export default function EventsPage() {
<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 ? (
{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 scheduled. Check back soon.
@@ -106,26 +76,23 @@ export default function EventsPage() {
</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} />)}
{upcoming.map((m) => (
<MeetupCard key={m.id} meetup={m} />
))}
</div>
)}
</div>
{(loading || past.length > 0) && (
{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 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>
+10
View File
@@ -0,0 +1,10 @@
import { buildFaqMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildFaqMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+29 -70
View File
@@ -1,12 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { FaqPageJsonLd } from "@/components/public/JsonLd";
import { FaqAccordion } from "@/components/public/FaqAccordion";
import { FaqPageJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface FaqItem {
id: string;
@@ -16,25 +12,35 @@ interface FaqItem {
showOnHomepage: boolean;
}
export default function FaqPage() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
async function fetchFaqs(): Promise<FaqItem[]> {
try {
const res = await fetch(apiUrl("/faqs?all=true"), {
next: { revalidate: 300 },
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
useEffect(() => {
api.getFaqsAll()
.then((data) => {
if (Array.isArray(data)) setItems(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
export default async function FaqPage() {
const items = await fetchFaqs();
return (
<>
{items.length > 0 && (
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
<FaqPageJsonLd
items={items.map((i) => ({ question: i.question, answer: i.answer }))}
/>
)}
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "FAQ", href: "/faq" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
@@ -43,57 +49,10 @@ export default function FaqPage() {
Everything you need to know about the Belgian Bitcoin Embassy.
</p>
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-surface-container-low rounded-xl h-[72px] animate-pulse" />
))}
</div>
)}
{!loading && items.length === 0 && (
{items.length === 0 ? (
<p className="text-on-surface-variant">No FAQs available yet.</p>
)}
{!loading && items.length > 0 && (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
) : (
<FaqAccordion items={items} />
)}
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
import { buildHomeMarkdown } from "@/lib/llms";
export const revalidate = 300;
export async function GET() {
const body = await buildHomeMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+5 -13
View File
@@ -2,6 +2,7 @@ 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 { fetchPublicSettings, socialUrlsFromSettings } from "@/lib/seo";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
@@ -24,17 +25,6 @@ export const metadata: Metadata = {
},
description:
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
keywords: [
"Bitcoin",
"Belgium",
"Antwerp",
"Bitcoin meetup",
"Bitcoin education",
"Nostr",
"Belgian Bitcoin Embassy",
"Bitcoin community Belgium",
"Bitcoin events Antwerp",
],
authors: [{ name: "Belgian Bitcoin Embassy" }],
creator: "Belgian Bitcoin Embassy",
publisher: "Belgian Bitcoin Embassy",
@@ -90,7 +80,9 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await fetchPublicSettings();
const sameAs = socialUrlsFromSettings(settings);
return (
<html lang="en" dir="ltr" className="dark">
<body>
@@ -102,7 +94,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
strategy="afterInteractive"
/>
) : null}
<OrganizationJsonLd />
<OrganizationJsonLd sameAs={sameAs} />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>
</body>
+10
View File
@@ -0,0 +1,10 @@
import { buildLlmsTxt } from "@/lib/llms";
export const revalidate = 3600;
export async function GET() {
const body = await buildLlmsTxt();
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+7
View File
@@ -0,0 +1,7 @@
import { PRIVACY_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(PRIVACY_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Privacy Policy",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Privacy Policy", href: "/privacy" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+7
View File
@@ -0,0 +1,7 @@
import { TERMS_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(TERMS_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Terms of Use",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Terms of Use", href: "/terms" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export interface BlogPost {
id: string;
slug: string;
title: string;
excerpt?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
}
export interface BlogCategory {
id: string;
name: string;
slug: string;
}
interface BlogIndexProps {
initialPosts: BlogPost[];
initialTotal: number;
categories: BlogCategory[];
limit: number;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
export function BlogIndex({
initialPosts,
initialTotal,
categories,
limit,
}: BlogIndexProps) {
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [total, setTotal] = useState(initialTotal);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isFirst = useRef(true);
useEffect(() => {
// The initial ("all", page 1) data is already rendered from the server.
if (isFirst.current) {
isFirst.current = false;
return;
}
let cancelled = false;
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
if (cancelled) return;
setPosts(data);
setTotal(t);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeCategory, page, limit]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
return (
<>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
setActiveCategory("all");
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
setActiveCategory(cat.slug);
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((pc) => (
<span
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{pc.category.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>{formatDate(post.publishedAt || post.createdAt!)}</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FaqAccordionItem {
id: string;
question: string;
answer: string;
}
/**
* Interactive accordion. Items are passed in from the server so the full
* question + answer text is present in the initial HTML (indexable), while the
* open/close behaviour is hydrated on the client.
*/
export function FaqAccordion({ items }: { items: FaqAccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
aria-expanded={isOpen}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
+8 -2
View File
@@ -14,18 +14,21 @@ export function JsonLd({ data }: JsonLdProps) {
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export function OrganizationJsonLd() {
export function OrganizationJsonLd({ sameAs }: { sameAs?: string[] } = {}) {
const sameAsUrls =
sameAs && sameAs.length > 0 ? sameAs : ["https://t.me/belgianbitcoinembassy"];
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: "Belgian Bitcoin Embassy",
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
sameAs: sameAsUrls,
address: {
"@type": "PostalAddress",
addressLocality: "Antwerp",
@@ -103,6 +106,7 @@ interface EventJsonLdProps {
name: string;
description?: string;
startDate: string;
endDate?: string;
location?: string;
url: string;
imageUrl?: string;
@@ -114,6 +118,7 @@ export function EventJsonLd({
name,
description,
startDate,
endDate,
location,
url,
imageUrl,
@@ -130,6 +135,7 @@ export function EventJsonLd({
name,
description: description || `Bitcoin meetup: ${name}`,
startDate,
...(endDate ? { endDate } : {}),
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
...(location
+106
View File
@@ -0,0 +1,106 @@
/**
* Plain-markdown mirrors of the legal pages, served at /privacy.md and /terms.md
* for llms.txt consumers.
*
* SOURCE OF TRUTH: these must be kept in sync with the rendered pages at
* app/privacy/page.tsx and app/terms/page.tsx. If you edit the legal copy there,
* update it here too (and bump "Last updated").
*/
export const PRIVACY_MARKDOWN = `# Privacy Policy
_Last updated: April 3, 2026_
## Who We Are
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.
## What Data We Process
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.
## Why We Process Data
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).
## Cookies and Local Storage
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.
## Recipients
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.
## Retention
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.
## Your GDPR Rights
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.
## International Transfers
If technical providers process data outside the EEA, we aim to rely on appropriate safeguards as required under GDPR.
## Children
This website is not directed at children under the age of 16.
## Policy Updates
We may update this Privacy Policy from time to time. Material changes are reflected by updating the date at the top of this page.
## Contact
For privacy-related questions, reach out to us via our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
export const TERMS_MARKDOWN = `# Terms of Use
_Last updated: April 3, 2026_
## Acceptance and Changes
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.
## Nature of the Service
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.
## Crypto Risk Warning
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.
## MiCA and Regulatory Position
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.
## Content and Third Parties
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.
## User Conduct
Users interacting via Nostr (likes, comments) are expected to behave respectfully. The moderation team reserves the right to locally hide content or block pubkeys that violate community standards.
## Paid and Commercial Features
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.
## Affiliate and Sponsorship Transparency
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.
## Disclaimer and Liability
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.
## Governing Law
These terms are governed by Belgian law, without prejudice to mandatory consumer protections that apply in your jurisdiction.
## Contact
For terms-related questions, contact us through our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
+331
View File
@@ -0,0 +1,331 @@
/**
* Server-side builders for the llms.txt file (llmstxt.org) and the plain-markdown
* page mirrors (`<path>.md`). Everything here is generated at request time from
* live data (settings, meetups, FAQs, posts) so it never goes stale.
*
* Mirrors deliberately omit nav, footer, and the legal disclaimer block an LLM
* reading these needs the actual content, not repeated chrome.
*/
import { apiUrl } from "./api-base";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "./meetupEventTime";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
function formatPostDate(value?: string): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
/** Social channels with their human description, in display order. */
const SOCIAL_CHANNELS: { key: string; name: string; description: string }[] = [
{
key: "telegram_link",
name: "Telegram",
description:
"Main Belgian chat group for daily discussion and local coordination.",
},
{
key: "nostr_link",
name: "Nostr",
description:
"Follow the BBE on the censorship-resistant social protocol.",
},
{
key: "x_link",
name: "X",
description: "Latest local announcements and event drops.",
},
{
key: "youtube_link",
name: "YouTube",
description: "Past talks, educational content, and meetup recordings.",
},
{
key: "discord_link",
name: "Discord",
description:
"Technical discussions, node running, and project collaboration.",
},
{
key: "linkedin_link",
name: "LinkedIn",
description: "The Belgian Bitcoin professional network.",
},
];
function configuredChannels(settings: Record<string, string>) {
return SOCIAL_CHANNELS.map((c) => ({ ...c, url: settings[c.key]?.trim() }))
.filter((c): c is typeof c & { url: string } =>
!!c.url && /^https?:\/\//i.test(c.url),
);
}
// ---------------------------------------------------------------------------
// llms.txt
// ---------------------------------------------------------------------------
export async function buildLlmsTxt(): Promise<string> {
const [settings, meetups] = await Promise.all([
fetchJson<Record<string, string>>("/settings/public", {}),
fetchJson<any[]>("/meetups", []),
]);
const now = new Date();
const upcomingCount = (Array.isArray(meetups) ? meetups : []).filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
}).length;
const channels = configuredChannels(settings);
const channelNames = channels.map((c) => c.name).join(", ");
const communityDescription = channelNames
? `How to connect on ${channelNames}`
: "How to connect with the community";
const upcomingLine =
upcomingCount > 0
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
upcomingCount === 1 ? "" : "s"
} scheduled.`
: "Meetups run monthly; check the events page for the next date.";
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here covers meetups, FAQs about the community, and curated Bitcoin/Nostr commentary. ${upcomingLine}
## Pages
- [About](${SITE_URL}/index.html.md): Who we are and what we do
- [Events](${SITE_URL}/events.md): Upcoming and past Bitcoin meetups in Belgium
- [FAQ](${SITE_URL}/faq.md): Common questions about the community and how to get involved
- [Blog](${SITE_URL}/blog.md): Curated Bitcoin and Nostr content
- [Community](${SITE_URL}/community.md): ${communityDescription}
## Optional
- [Privacy](${SITE_URL}/privacy.md)
- [Terms](${SITE_URL}/terms.md)
- [Contact](${SITE_URL}/contact.md)
`;
}
// ---------------------------------------------------------------------------
// Page mirrors
// ---------------------------------------------------------------------------
export async function buildHomeMarkdown(): Promise<string> {
const meetups = await fetchJson<any[]>("/meetups", []);
const now = new Date();
const next = (Array.isArray(meetups) ? meetups : [])
.filter((m) => {
if (m.status && m.status !== "PUBLISHED") return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
})
.sort(
(a, b) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime(),
)[0];
let nextSection = "";
if (next) {
const when = formatMeetupCivilDateLong(next.date);
const bits = [when, next.time, next.location].filter(Boolean).join(" · ");
nextSection = `
## Next Meetup
**${next.title}** ${bits}
See all events: ${SITE_URL}/events.md`;
}
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
## The Mission
"Fix the money, fix the world."
We help people in Belgium understand and adopt Bitcoin through education, meetups, and community. We are not a company, but a sovereign network of individuals building a sounder future.${nextSection}
## More
- Events: ${SITE_URL}/events.md
- FAQ: ${SITE_URL}/faq.md
- Blog: ${SITE_URL}/blog.md
- Community: ${SITE_URL}/community.md
`;
}
export async function buildEventsMarkdown(): Promise<string> {
const meetups = await fetchJson<any[]>("/meetups", []);
const list = Array.isArray(meetups) ? meetups : [];
const now = new Date();
const upcoming = list.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
});
const past = list
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start < now;
})
.reverse();
const renderMeetup = (m: any): string => {
const when = formatMeetupCivilDateLong(m.date);
const meta = [when, m.time, m.location].filter(Boolean).join(" · ");
const organizer = m.organizer?.name || "Belgian Bitcoin Embassy";
const lines = [`### ${m.title}`, "", `${meta}`, "", `Organized by ${organizer}.`];
if (m.description) lines.push("", m.description.trim());
lines.push("", `Details: ${SITE_URL}/events/${m.id}`);
return lines.join("\n");
};
const sections: string[] = [
"# Events",
"",
"Past and upcoming Bitcoin meetups in Belgium, organized by the Belgian Bitcoin Embassy.",
];
sections.push("", "## Upcoming");
sections.push(
"",
upcoming.length
? upcoming.map(renderMeetup).join("\n\n")
: "No upcoming events are currently scheduled. Check back soon.",
);
if (past.length) {
sections.push("", "## Past Events", "", past.map(renderMeetup).join("\n\n"));
}
return sections.join("\n") + "\n";
}
export async function buildFaqMarkdown(): Promise<string> {
const faqs = await fetchJson<any[]>("/faqs?all=true", []);
const list = Array.isArray(faqs) ? faqs : [];
const header = `# Frequently Asked Questions
Everything you need to know about the Belgian Bitcoin Embassy.`;
if (!list.length) {
return `${header}\n\nNo FAQs are available yet.\n`;
}
const body = list
.map((f) => `## ${f.question}\n\n${(f.answer || "").trim()}`)
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildBlogMarkdown(): Promise<string> {
const data = await fetchJson<{ posts: any[]; total: number }>(
"/posts?limit=100",
{ posts: [], total: 0 },
);
const posts = Array.isArray(data?.posts) ? data.posts : [];
const header = `# Blog
Curated Bitcoin and Nostr content from the Belgian Bitcoin Embassy.`;
if (!posts.length) {
return `${header}\n\nNo posts have been published yet.\n`;
}
const body = posts
.map((p) => {
const date = formatPostDate(p.publishedAt || p.createdAt);
const meta = [p.author, date].filter(Boolean).join(" · ");
const lines = [`## ${p.title}`];
if (meta) lines.push("", `_${meta}_`);
if (p.excerpt) lines.push("", p.excerpt.trim());
lines.push("", `Read: ${SITE_URL}/blog/${p.slug}`);
return lines.join("\n");
})
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildCommunityMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Community
Connect with local Belgian Bitcoiners, builders, and educators across every platform.`;
if (!channels.length) {
return `${header}\n\nCommunity channel links are being set up. Check back soon.\n`;
}
const body = channels
.map((c) => `- [${c.name}](${c.url}): ${c.description}`)
.join("\n");
return `${header}\n\n## Channels\n\n${body}\n`;
}
export async function buildContactMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Contact
The best way to reach us is through our community channels. We are a decentralized community there is no central office or email inbox.`;
const lines: string[] = [header, "", "## Channels"];
if (channels.length) {
lines.push(
"",
...channels.map((c) => `- [${c.name}](${c.url}): ${c.description}`),
);
} else {
lines.push("", "Community channel links are being set up. Check back soon.");
}
lines.push(
"",
"## Meetups",
"",
`The best way to connect is in person. Come to our monthly meetup — see upcoming events at ${SITE_URL}/events.md`,
);
return lines.join("\n") + "\n";
}
+21
View File
@@ -52,6 +52,27 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}
/**
* Returns the event end instant in UTC when the time string carries a range
* (e.g. "18:00 - 21:00"), otherwise null. Used for schema.org Event.endDate.
*/
export function getMeetupEndUtc(dateStr: string, timeStr: string): Date | null {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return null;
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const day = parts[2];
if (!year || !month || !day) return null;
const timeParts = (timeStr?.trim() || "").split(/\s*[-]\s*/);
if (timeParts.length < 2 || !timeParts[1]?.trim()) return null;
const { h: endH, m: endM } = parseLocalTime(timeParts[1]);
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
}
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
/**
+38
View File
@@ -0,0 +1,38 @@
import { apiUrl } from "./api-base";
/**
* Server-side fetch of the public site settings (social links, titles, ).
* Cached/revalidated so it doesn't hit the backend on every render.
*/
export async function fetchPublicSettings(): Promise<Record<string, string>> {
try {
const res = await fetch(apiUrl("/settings/public"), {
next: { revalidate: 3600 },
});
if (!res.ok) return {};
return (await res.json()) as Record<string, string>;
} catch {
return {};
}
}
/** Setting keys that hold a public social/profile URL, in display order. */
const SOCIAL_SETTING_KEYS = [
"telegram_link",
"nostr_link",
"x_link",
"youtube_link",
"discord_link",
"linkedin_link",
] as const;
/**
* Build the list of real social URLs for schema.org `sameAs` from settings.
* Only includes entries that are actually configured (non-empty, http(s)).
*/
export function socialUrlsFromSettings(
settings: Record<string, string>,
): string[] {
return SOCIAL_SETTING_KEYS.map((key) => settings[key]?.trim())
.filter((url): url is string => !!url && /^https?:\/\//i.test(url));
}