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
+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>