Files
BelgianBitcoinEmbassy/frontend/components/public/MeetupsSection.tsx
T
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00

167 lines
6.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
// Initial number of meetups shown before "Load more". Mobile is enforced via
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
const INITIAL_MOBILE = 3;
const INITIAL_DESKTOP = 6;
interface MeetupData {
id?: string;
title: string;
date: string;
time?: string;
location?: string;
link?: string;
description?: string;
organizer?: { name: string; slug?: string };
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
// Number of extra batches revealed via "Load more". Each batch adds 3 on
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
const [pages, setPages] = useState(0);
const mobileVisible = INITIAL_MOBILE * (pages + 1);
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
// Visibility per card. Enforced with CSS so the server-rendered markup matches
// both viewports (desktopVisible >= mobileVisible, so "mobile-only" never occurs).
const cardVisibilityClass = (i: number) => {
if (i < mobileVisible) return "flex";
if (i < desktopVisible) return "hidden md:flex";
return "hidden";
};
// Show the button only when content is still hidden at the given breakpoint.
let loadMoreClass = "hidden";
if (meetups.length > desktopVisible) loadMoreClass = "flex";
else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden";
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">
<AddToCalendarButton />
<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 civil = formatMeetupCivilDate(meetup.date);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
const full = civil?.full ?? "";
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
<Link
key={meetup.id ?? i}
href={href}
className={`group ${cardVisibilityClass(i)} 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>
)}
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
Organized by{" "}
<span className="text-on-surface-variant/70 normal-case tracking-normal">
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</p>
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className="shrink-0 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={`${loadMoreClass} justify-center mt-10`}>
<button
type="button"
onClick={() => setPages((p) => p + 1)}
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
>
Load more
</button>
</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>
<AddToCalendarButton />
</div>
</div>
</section>
);
}