first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
export function AboutSection() {
return (
<section id="about" className="py-32 px-8">
<div className="max-w-5xl mx-auto text-center">
<span className="uppercase tracking-[0.3em] text-primary mb-8 block text-sm font-semibold">
The Mission
</span>
<h2 className="text-4xl md:text-5xl font-black mb-10 leading-tight">
&ldquo;Fix the money, fix the world.&rdquo;
</h2>
<p className="text-2xl text-on-surface-variant font-light leading-relaxed mb-12">
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.
</p>
<div className="w-24 h-1 bg-primary mx-auto opacity-50" />
</div>
</section>
);
}

View File

@@ -0,0 +1,81 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
interface BlogPost {
slug: string;
title: string;
excerpt: string;
categories: string[];
}
interface BlogPreviewSectionProps {
posts?: BlogPost[];
}
export function BlogPreviewSection({ posts }: BlogPreviewSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
From the network
</p>
<h2 className="text-3xl font-black tracking-tight">Latest from the Blog</h2>
</div>
<Link
href="/blog"
className="hidden md:flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
View All <ArrowRight size={16} />
</Link>
</div>
{!posts || posts.length === 0 ? (
<p className="text-on-surface-variant text-center py-16 text-sm">
No posts yet. Check back soon for curated Bitcoin content.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{posts.map((post) => (
<Link
key={post.slug}
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.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
<span
key={cat}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all mt-auto">
Read More <ArrowRight size={13} />
</span>
</Link>
))}
</div>
)}
<Link
href="/blog"
className="md:hidden flex items-center justify-center gap-2 text-primary font-semibold mt-8 text-sm"
>
View All <ArrowRight size={16} />
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,169 @@
import { type SVGProps } from "react";
interface PlatformDef {
name: string;
description: string;
settingKey: string;
Icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
}
function IconTelegram(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
);
}
function IconNostr(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
function IconX(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
function IconYouTube(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 11.75a29 29 0 00.46 5.33 2.78 2.78 0 001.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 001.94-2 29 29 0 00.46-5.33 29 29 0 00-.46-5.33z" />
<path d="M9.75 15.02l5.75-3.27-5.75-3.27v6.54z" />
</svg>
);
}
function IconDiscord(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
function IconLinkedIn(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zM7.119 20.452H3.554V9h3.565v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
);
}
function IconArrowOut(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M7 17l9.2-9.2M17 17V7H7" />
</svg>
);
}
const PLATFORMS: PlatformDef[] = [
{
name: "Telegram",
description: "Join the main Belgian chat group for daily discussion and local coordination.",
settingKey: "telegram_link",
Icon: IconTelegram,
},
{
name: "Nostr",
description: "Follow the BBE on the censorship-resistant social protocol for true signal.",
settingKey: "nostr_link",
Icon: IconNostr,
},
{
name: "X",
description: "Stay updated with our latest local announcements and event drops.",
settingKey: "x_link",
Icon: IconX,
},
{
name: "YouTube",
description: "Watch past talks, educational content, and high-quality BBE meetup recordings.",
settingKey: "youtube_link",
Icon: IconYouTube,
},
{
name: "Discord",
description: "Deep dive into technical discussions, node running, and project collaboration.",
settingKey: "discord_link",
Icon: IconDiscord,
},
{
name: "LinkedIn",
description: "Connect with the Belgian Bitcoin professional network and industry leaders.",
settingKey: "linkedin_link",
Icon: IconLinkedIn,
},
];
interface CommunityLinksSectionProps {
settings?: Record<string, string>;
}
export function CommunityLinksSection({ settings = {} }: CommunityLinksSectionProps) {
return (
<section
id="community"
className="relative py-16 sm:py-20 px-4 sm:px-8"
>
<div className="max-w-[1100px] mx-auto w-full">
<header className="text-center mb-8 sm:mb-10">
<h2 className="text-[1.75rem] sm:text-4xl font-extrabold tracking-tight text-white mb-2">
Join the <span className="text-[#F7931A]">community</span>
</h2>
<p className="text-zinc-400 text-sm sm:text-[0.95rem] max-w-[550px] mx-auto leading-relaxed">
Connect with local Belgian Bitcoiners, builders, and educators.
</p>
</header>
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{PLATFORMS.map((platform) => {
const href = settings[platform.settingKey] || "#";
const isExternal = href.startsWith("http");
const Icon = platform.Icon;
return (
<a
key={platform.name}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="group relative flex flex-col rounded-xl border border-zinc-800 bg-zinc-900 p-5 no-underline overflow-hidden transition-all duration-300 hover:-translate-y-[3px] hover:border-[rgba(247,147,26,0.4)] hover:shadow-[0_10px_25px_-10px_rgba(0,0,0,0.5)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#F7931A]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-[radial-gradient(circle_at_top_right,rgba(247,147,26,0.08),transparent_60%)]"
/>
<div className="relative z-[1] flex items-center justify-between mb-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-black text-[#F7931A] transition-all duration-300 group-hover:scale-105 group-hover:-rotate-[5deg] group-hover:border-[#F7931A] group-hover:bg-[#F7931A] group-hover:text-black">
<Icon className="h-[18px] w-[18px] shrink-0" aria-hidden />
</span>
<h3 className="text-[1.05rem] font-bold text-white truncate transition-colors duration-300 group-hover:text-[#F7931A]">
{platform.name}
</h3>
</div>
<IconArrowOut
className="h-[18px] w-[18px] shrink-0 text-zinc-600 transition-all duration-300 group-hover:text-[#F7931A] group-hover:translate-x-[3px] group-hover:-translate-y-[3px]"
aria-hidden
/>
</div>
<p className="relative z-[1] text-[0.85rem] leading-relaxed text-zinc-400">
{platform.description}
</p>
</a>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
export function FAQSection() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
api.getFaqs().catch(() => []).then((data) => {
if (Array.isArray(data)) setItems(data);
});
}, []);
if (items.length === 0) return null;
return (
<section id="faq" className="py-24 px-8">
<div className="max-w-3xl mx-auto">
<h2 className="text-4xl font-black mb-16 text-center">
Frequently Asked Questions
</h2>
<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>
</div>
</section>
);
}

View File

@@ -0,0 +1,44 @@
import { Send, Bitcoin } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface FinalCTASectionProps {
telegramLink?: string;
}
export function FinalCTASection({ telegramLink }: FinalCTASectionProps) {
return (
<section className="py-32 px-8 bg-surface-container-low relative overflow-hidden">
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="text-5xl font-black mb-8">
Join us
</h2>
<p className="text-on-surface-variant text-xl mb-12">
The best time to learn was 10 years ago. The second best time is
today. Join the community.
</p>
<div className="flex flex-col md:flex-row items-center justify-center gap-6">
<a
href={telegramLink || "#community"}
target={telegramLink ? "_blank" : undefined}
rel={telegramLink ? "noopener noreferrer" : undefined}
>
<Button variant="telegram" size="lg" className="w-full md:w-auto flex items-center justify-center gap-2">
<Send size={18} /> Join Telegram
</Button>
</a>
<a href="/events">
<Button variant="primary" size="lg" className="w-full md:w-auto">
Attend Meetup
</Button>
</a>
</div>
</div>
<Bitcoin
size={400}
className="absolute -bottom-20 -right-20 opacity-5 text-on-surface"
/>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import Link from "next/link";
const LINKS = [
{ label: "FAQ", href: "/faq" },
{ label: "Community", href: "/community" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
{ label: "Contact", href: "/contact" },
];
export function Footer() {
return (
<footer className="w-full py-12 bg-surface-container-lowest">
<div className="flex flex-col items-center justify-center space-y-6 w-full px-8 text-center">
<Link href="/" className="text-lg font-black text-primary-container">
Belgian Bitcoin Embassy
</Link>
<nav aria-label="Footer navigation" className="flex space-x-12">
{LINKS.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-white opacity-50 hover:opacity-100 transition-opacity text-sm tracking-widest uppercase"
>
{link.label}
</Link>
))}
</nav>
<p className="text-white opacity-50 text-sm tracking-widest uppercase">
&copy; Belgian Bitcoin Embassy. No counterparty risk.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,71 @@
import { ArrowRight, MapPin } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
month?: string;
day?: string;
title?: string;
location?: string;
time?: string;
link?: string;
}
interface HeroSectionProps {
meetup?: MeetupData;
}
export function HeroSection({ meetup }: HeroSectionProps) {
const month = meetup?.month ?? "TBD";
const day = meetup?.day ?? "--";
const title = meetup?.title ?? "Next Gathering";
const location = meetup?.location ?? "Brussels, BE";
const time = meetup?.time ?? "19:00";
const eventHref = meetup?.id ? `/events/${meetup.id}` : "#meetup";
return (
<section className="pt-32 pb-24 px-8">
<div className="max-w-4xl mx-auto text-center">
<span className="inline-block uppercase tracking-[0.25em] text-primary mb-8 font-semibold text-xs border border-primary/20 px-4 py-1.5 rounded-full">
Antwerp, Belgium
</span>
<h1 className="text-5xl md:text-7xl font-black tracking-tighter leading-[0.95] mb-6">
Belgium&apos;s Monthly
<br />
<span className="text-primary">Bitcoin Meetups</span>
</h1>
<p className="text-lg text-on-surface-variant max-w-md mx-auto leading-relaxed mb-14">
A sovereign space for education, technical discussion, and community.
No hype, just signal.
</p>
<div className="inline-flex flex-col sm:flex-row items-stretch sm:items-center gap-4 bg-zinc-900 border border-zinc-800 rounded-2xl p-4 sm:p-5 w-full max-w-xl">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="bg-zinc-800 rounded-xl px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="text-left min-w-0">
<p className="font-bold text-base truncate">{title}</p>
<p className="text-on-surface-variant text-sm flex items-center gap-1 mt-0.5">
<MapPin size={12} className="shrink-0" />
<span className="truncate">{location} · {time}</span>
</p>
</div>
</div>
<Link
href={eventHref}
className="flex items-center justify-center gap-2 bg-primary text-on-primary px-6 py-3 rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shrink-0"
>
More info <ArrowRight size={16} />
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,199 @@
interface JsonLdProps {
data: Record<string, unknown>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export function OrganizationJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
address: {
"@type": "PostalAddress",
addressLocality: "Antwerp",
addressCountry: "BE",
},
}}
/>
);
}
export function WebSiteJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "WebSite",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
}}
/>
);
}
interface BlogPostingJsonLdProps {
title: string;
description: string;
slug: string;
publishedAt?: string;
authorName?: string;
}
export function BlogPostingJsonLd({
title,
description,
slug,
publishedAt,
authorName,
}: BlogPostingJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title,
description,
url: `${siteUrl}/blog/${slug}`,
...(publishedAt ? { datePublished: publishedAt } : {}),
author: {
"@type": "Person",
name: authorName || "Belgian Bitcoin Embassy",
},
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
image: `${siteUrl}/og?title=${encodeURIComponent(title)}&type=blog`,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${siteUrl}/blog/${slug}`,
},
}}
/>
);
}
interface EventJsonLdProps {
name: string;
description?: string;
startDate: string;
location?: string;
url: string;
imageUrl?: string;
}
export function EventJsonLd({
name,
description,
startDate,
location,
url,
imageUrl,
}: EventJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Event",
name,
description: description || `Bitcoin meetup: ${name}`,
startDate,
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
...(location
? {
location: {
"@type": "Place",
name: location,
address: {
"@type": "PostalAddress",
addressLocality: location,
addressCountry: "BE",
},
},
}
: {}),
organizer: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
},
image:
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,
url,
}}
/>
);
}
interface FaqJsonLdProps {
items: { question: string; answer: string }[];
}
export function FaqPageJsonLd({ items }: FaqJsonLdProps) {
if (items.length === 0) return null;
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
}}
/>
);
}
interface BreadcrumbItem {
name: string;
href: string;
}
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${siteUrl}${item.href}`,
})),
}}
/>
);
}

View File

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

View File

@@ -0,0 +1,137 @@
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
title: string;
date: string;
time?: string;
location?: string;
link?: string;
description?: 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">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Mark your calendar
</p>
<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>
<Link
href="/events"
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
All events <ArrowRight size={16} />
</Link>
</div>
</div>
{meetups.length === 0 ? (
<div className="border border-zinc-800 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming meetups scheduled. Check back soon.
</p>
</div>
) : (
<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 href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
<Link
key={meetup.id ?? i}
href={href}
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"
>
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{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 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 text-primary/60" />
{meetup.time}
</p>
)}
</div>
<span className="flex items-center gap-1.5 text-primary text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all">
View Details <ArrowRight size={12} />
</span>
</Link>
);
})}
</div>
)}
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
<Link
href="/events"
className="flex items-center gap-2 text-primary font-semibold text-sm"
>
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>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Menu, X, LogIn, User, LayoutDashboard, LogOut, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/hooks/useAuth";
import { shortenPubkey } from "@/lib/nostr";
const SECTION_LINKS = [{ label: "About", anchor: "about" }];
const PAGE_LINKS = [
{ label: "Meetups", href: "/events" },
{ label: "Community", href: "/community" },
{ label: "FAQ", href: "/faq" },
];
function ProfileAvatar({
picture,
name,
size = 36,
}: {
picture?: string;
name?: string;
size?: number;
}) {
const [imgError, setImgError] = useState(false);
const initial = (name || "?")[0].toUpperCase();
if (picture && !imgError) {
return (
<Image
src={picture}
alt={name || "Profile"}
width={size}
height={size}
className="rounded-full object-cover"
style={{ width: size, height: size }}
onError={() => setImgError(true)}
unoptimized
/>
);
}
return (
<div
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm"
style={{ width: size, height: size }}
>
{initial}
</div>
);
}
export function Navbar() {
const [open, setOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const router = useRouter();
const { user, loading, logout } = useAuth();
const isHome = pathname === "/";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function sectionHref(anchor: string) {
return isHome ? `#${anchor}` : `/#${anchor}`;
}
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
function handleLogout() {
setDropdownOpen(false);
setOpen(false);
logout();
router.push("/");
}
return (
<nav className="sticky top-0 z-50 bg-surface/95 backdrop-blur-md">
<div className="flex justify-between items-center max-w-7xl mx-auto px-8 h-20">
<Link
href="/"
className="text-xl font-bold text-primary-container tracking-[-0.02em]"
>
Belgian Bitcoin Embassy
</Link>
<div className="hidden md:flex space-x-10 items-center">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
className="font-medium tracking-tight transition-colors duration-200 text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
</div>
<div className="hidden md:block">
{loading ? (
<div className="w-24 h-10" />
) : user ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors hover:bg-surface-container-high"
>
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface max-w-[120px] truncate">
{displayName}
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-52 bg-surface-container-high rounded-xl py-2 shadow-lg shadow-black/30">
<Link
href="/dashboard"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<LayoutDashboard size={16} className="text-on-surface-variant" />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<Shield size={16} className="text-on-surface-variant" />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors w-full text-left"
>
<LogOut size={16} className="text-on-surface-variant" />
Logout
</button>
</div>
)}
</div>
) : (
<Link href="/login">
<Button variant="primary" size="md">
<span className="flex items-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
<button
className="md:hidden text-on-surface"
onClick={() => setOpen(!open)}
aria-label="Toggle menu"
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{open && (
<div className="md:hidden bg-surface-container px-8 pb-6 space-y-4">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
onClick={() => setOpen(false)}
className="block py-2 font-medium tracking-tight transition-colors text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
{loading ? null : user ? (
<>
<div className="flex items-center gap-3 pt-4">
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface truncate">
{displayName}
</span>
</div>
<Link
href="/dashboard"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<LayoutDashboard size={16} />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<Shield size={16} />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors w-full"
>
<LogOut size={16} />
Logout
</button>
</>
) : (
<Link href="/login" onClick={() => setOpen(false)}>
<Button variant="primary" size="md" className="w-full mt-4">
<span className="flex items-center justify-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
)}
</nav>
);
}