first commit
Made-with: Cursor
This commit is contained in:
121
frontend/components/admin/AdminSidebar.tsx
Normal file
121
frontend/components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
FileText,
|
||||
Shield,
|
||||
Tag,
|
||||
Users,
|
||||
Radio,
|
||||
Settings,
|
||||
Wrench,
|
||||
LogOut,
|
||||
ArrowLeft,
|
||||
Inbox,
|
||||
ImageIcon,
|
||||
HelpCircle,
|
||||
} 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/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 },
|
||||
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
|
||||
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
|
||||
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
|
||||
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
|
||||
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
|
||||
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
|
||||
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
const shortPubkey = user?.pubkey
|
||||
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
|
||||
<div className="mb-8">
|
||||
<Link href="/" className="text-primary-container font-bold text-xl">
|
||||
BBE Admin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!user ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-on-surface/40 text-sm text-center">
|
||||
Please log in to access the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
|
||||
user.role === "ADMIN"
|
||||
? "bg-primary-container/20 text-primary"
|
||||
: "bg-secondary-container text-on-secondary-container"
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-lg transition-colors",
|
||||
active
|
||||
? "bg-surface-container-high text-primary"
|
||||
: "text-on-surface/70 hover:text-on-surface hover:bg-surface-container"
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-2 pt-6">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to site</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
155
frontend/components/admin/MediaPickerModal.tsx
Normal file
155
frontend/components/admin/MediaPickerModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X, Upload, Film, Check } from "lucide-react";
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: "image" | "video";
|
||||
mimeType: string;
|
||||
size: number;
|
||||
originalFilename: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MediaPickerModalProps {
|
||||
onSelect: (mediaId: string) => void;
|
||||
onClose: () => void;
|
||||
selectedId?: string | null;
|
||||
}
|
||||
|
||||
export function MediaPickerModal({ onSelect, onClose, selectedId }: MediaPickerModalProps) {
|
||||
const [media, setMedia] = useState<MediaItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
const data = await api.getMediaList();
|
||||
setMedia(data.filter((m: MediaItem) => m.type === "image"));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMedia();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.uploadMedia(file);
|
||||
await loadMedia();
|
||||
onSelect(result.id);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
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 className="relative bg-surface-container-low rounded-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
|
||||
<h2 className="text-lg font-semibold text-on-surface">Select Image</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-xs hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<Upload size={14} />
|
||||
{uploading ? "Uploading..." : "Upload New"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-on-surface/50 hover:text-on-surface transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-error text-sm px-5 pt-3">{error}</p>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-on-surface/50 text-sm">Loading media...</p>
|
||||
</div>
|
||||
) : media.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-on-surface/50 text-sm">No images available.</p>
|
||||
<p className="text-on-surface/30 text-xs mt-1">Upload an image to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all hover:border-primary/60",
|
||||
selectedId === item.id
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-transparent"
|
||||
)}
|
||||
>
|
||||
{item.type === "image" ? (
|
||||
<img
|
||||
src={`/media/${item.id}?w=200`}
|
||||
alt={item.originalFilename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-container-highest flex items-center justify-center">
|
||||
<Film size={24} className="text-on-surface/30" />
|
||||
</div>
|
||||
)}
|
||||
{selectedId === item.id && (
|
||||
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
||||
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check size={16} className="text-on-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/components/providers/AuthProvider.tsx
Normal file
8
frontend/components/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { ReactNode } from "react";
|
||||
import { AuthContext, useAuthProvider } from "@/hooks/useAuth";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const auth = useAuthProvider();
|
||||
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
8
frontend/components/providers/ClientProviders.tsx
Normal file
8
frontend/components/providers/ClientProviders.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { AuthProvider } from "./AuthProvider";
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return <AuthProvider>{children}</AuthProvider>;
|
||||
}
|
||||
23
frontend/components/public/AboutSection.tsx
Normal file
23
frontend/components/public/AboutSection.tsx
Normal 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">
|
||||
“Fix the money, fix the world.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
81
frontend/components/public/BlogPreviewSection.tsx
Normal file
81
frontend/components/public/BlogPreviewSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/components/public/CommunityLinksSection.tsx
Normal file
169
frontend/components/public/CommunityLinksSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/components/public/FAQSection.tsx
Normal file
77
frontend/components/public/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/components/public/FinalCTASection.tsx
Normal file
44
frontend/components/public/FinalCTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/components/public/Footer.tsx
Normal file
37
frontend/components/public/Footer.tsx
Normal 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">
|
||||
© Belgian Bitcoin Embassy. No counterparty risk.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
71
frontend/components/public/HeroSection.tsx
Normal file
71
frontend/components/public/HeroSection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
199
frontend/components/public/JsonLd.tsx
Normal file
199
frontend/components/public/JsonLd.tsx
Normal 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}`,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/components/public/KnowledgeCards.tsx
Normal file
49
frontend/components/public/KnowledgeCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/components/public/MeetupsSection.tsx
Normal file
137
frontend/components/public/MeetupsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
frontend/components/public/Navbar.tsx
Normal file
300
frontend/components/public/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/components/ui/Button.tsx
Normal file
41
frontend/components/ui/Button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "tertiary" | "telegram";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-gradient-to-r from-primary to-primary-container text-on-primary font-bold hover:scale-105 active:opacity-80 transition-all",
|
||||
secondary:
|
||||
"bg-surface-container-highest text-on-surface hover:bg-surface-bright transition-colors",
|
||||
tertiary: "text-primary-fixed-dim hover:opacity-80 transition-opacity",
|
||||
telegram:
|
||||
"bg-[#24A1DE] text-white hover:opacity-90 transition-opacity",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "px-4 py-2 text-sm rounded-md",
|
||||
md: "px-6 py-2.5 rounded-lg",
|
||||
lg: "px-10 py-4 rounded-lg font-bold",
|
||||
};
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = "primary", size = "md", className, children, ...rest }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(variantStyles[variant], sizeStyles[size], className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
export { Button, type ButtonProps };
|
||||
27
frontend/components/ui/Card.tsx
Normal file
27
frontend/components/ui/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type HTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
hover?: boolean;
|
||||
variant?: "low" | "default";
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ hover, variant = "low", className, children, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl p-6",
|
||||
variant === "low" ? "bg-surface-container-low" : "bg-surface-container",
|
||||
hover && "hover:bg-surface-container-high transition-colors",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
export { Card, type CardProps };
|
||||
Reference in New Issue
Block a user