feat: organizers, meetups UI, Plausible analytics, and migration tooling
- Add organizer model/API, admin and public organizer pages, meetup cards - Refresh events/home/contact; add calendar dialog and carousel components - Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout - Prisma migration, seed updates, baseline-and-migrate script Made-with: Cursor
This commit is contained in:
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
|
||||
|
||||
const siteUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
function Dialog({ onClose }: { onClose: () => void }) {
|
||||
const icsUrl = `${siteUrl}/calendar.ics`;
|
||||
const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(icsUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-md p-6 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-on-surface-variant/50 hover:text-on-surface transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="bg-primary/10 text-primary rounded-lg p-2">
|
||||
<CalendarPlus size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold">Add to Calendar</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-on-surface-variant leading-relaxed mb-6">
|
||||
Subscribe to this feed to get all public Belgian Bitcoin Embassy
|
||||
meetups in your calendar. New events are added automatically.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-on-surface-variant/60 mb-1.5">
|
||||
Calendar URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={icsUrl}
|
||||
className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-on-surface select-all focus:outline-none focus:border-primary/50"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2 text-xs font-medium transition-all"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<a
|
||||
href={webcalUrl}
|
||||
className="flex items-center justify-center gap-1.5 bg-primary text-on-primary rounded-lg px-3 py-2.5 text-xs font-semibold hover:brightness-110 transition-all"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in Calendar
|
||||
</a>
|
||||
<a
|
||||
href="/calendar.ics"
|
||||
download="bbe-events.ics"
|
||||
className="flex items-center justify-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2.5 text-xs font-semibold transition-all"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download .ics
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddToCalendarButton({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title="Subscribe to get all future meetups automatically"
|
||||
className={
|
||||
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
|
||||
</button>
|
||||
{open && <Dialog onClose={close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
frontend/components/public/ContactChannelGrid.tsx
Normal file
75
frontend/components/public/ContactChannelGrid.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Send, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
key: "telegram_link" as const,
|
||||
title: "Telegram",
|
||||
description:
|
||||
"Join our Telegram group for quick questions and community chat.",
|
||||
Icon: Send,
|
||||
},
|
||||
{
|
||||
key: "nostr_link" as const,
|
||||
title: "Nostr",
|
||||
description: "Follow us on Nostr for censorship-resistant communication.",
|
||||
Icon: Zap,
|
||||
},
|
||||
{
|
||||
key: "x_link" as const,
|
||||
title: "X (Twitter)",
|
||||
description: "Follow us on X for announcements and updates.",
|
||||
Icon: ExternalLink,
|
||||
},
|
||||
];
|
||||
|
||||
export function ContactChannelGrid() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getPublicSettings()
|
||||
.then((data) => setSettings(data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{CHANNELS.map(({ key, title, description, Icon }) => {
|
||||
const href = settings[key] || "#";
|
||||
const isExternal = href.startsWith("http");
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<Icon size={28} className="text-primary mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">{title}</h2>
|
||||
<p className="text-on-surface-variant text-sm">{description}</p>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="bg-surface-container-low p-8 rounded-xl">
|
||||
<h2 className="text-xl font-bold mb-2">Meetups</h2>
|
||||
<p className="text-on-surface-variant text-sm mb-4">
|
||||
The best way to connect is in person. Come to our monthly meetup in
|
||||
Brussels.
|
||||
</p>
|
||||
<Link
|
||||
href="/#meetup"
|
||||
className="text-primary font-bold text-sm hover:underline"
|
||||
>
|
||||
See next meetup →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,23 @@ export function Footer() {
|
||||
<p className="text-white opacity-50 text-xs sm:text-sm tracking-widest uppercase max-w-[min(100%,22rem)] sm:max-w-md leading-relaxed text-balance">
|
||||
© Belgian Bitcoin Embassy.
|
||||
</p>
|
||||
|
||||
<div className="max-w-3xl text-white/65 text-xs sm:text-sm leading-relaxed text-left">
|
||||
<h2 className="text-white/75 font-semibold mb-2">Disclaimer</h2>
|
||||
<p>
|
||||
The Belgian Bitcoin Embassy provides information for educational purposes only and
|
||||
does not offer financial, investment, or legal advice. Bitcoin and other
|
||||
cryptocurrencies are subject to high volatility and potential risks. Always conduct
|
||||
your own research and consult a qualified professional before making any financial
|
||||
decisions. The Embassy is not responsible for any losses or damages resulting from the
|
||||
use of this information.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Cryptocurrencies may be subject to regulatory changes; ensure compliance with local
|
||||
laws. Remember to practice safe security measures, including the use of secure wallets
|
||||
and private key protection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -106,6 +106,8 @@ interface EventJsonLdProps {
|
||||
location?: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
organizerName?: string;
|
||||
organizerUrl?: string;
|
||||
}
|
||||
|
||||
export function EventJsonLd({
|
||||
@@ -115,7 +117,11 @@ export function EventJsonLd({
|
||||
location,
|
||||
url,
|
||||
imageUrl,
|
||||
organizerName,
|
||||
organizerUrl,
|
||||
}: EventJsonLdProps) {
|
||||
const orgName = organizerName || "Belgian Bitcoin Embassy";
|
||||
const orgUrl = organizerUrl || siteUrl;
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
@@ -141,8 +147,8 @@ export function EventJsonLd({
|
||||
: {}),
|
||||
organizer: {
|
||||
"@type": "Organization",
|
||||
name: "Belgian Bitcoin Embassy",
|
||||
url: siteUrl,
|
||||
name: orgName,
|
||||
url: orgUrl,
|
||||
},
|
||||
image:
|
||||
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
67
frontend/components/public/MeetupCard.tsx
Normal file
67
frontend/components/public/MeetupCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
||||
|
||||
export function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
|
||||
const civil = formatMeetupCivilDate(meetup.date);
|
||||
const month = civil?.monthShort ?? "—";
|
||||
const day = civil?.day ?? "--";
|
||||
const full = civil?.full ?? "";
|
||||
return (
|
||||
<Link
|
||||
href={`/events/${meetup.id}`}
|
||||
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
|
||||
muted
|
||||
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
|
||||
: "border-zinc-800 hover:border-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
|
||||
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
|
||||
{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 ${muted ? "text-on-surface-variant/40" : "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 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
|
||||
{meetup.time}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
|
||||
View Details <ArrowRight size={12} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
|
||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
||||
|
||||
interface MeetupData {
|
||||
id?: string;
|
||||
@@ -9,21 +11,13 @@ interface MeetupData {
|
||||
location?: string;
|
||||
link?: string;
|
||||
description?: string;
|
||||
organizer?: { name: string; slug?: 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">
|
||||
@@ -36,14 +30,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
<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>
|
||||
<AddToCalendarButton />
|
||||
<Link
|
||||
href="/events"
|
||||
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
|
||||
@@ -62,7 +49,10 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
) : (
|
||||
<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 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 (
|
||||
@@ -92,6 +82,13 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
</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">
|
||||
@@ -123,13 +120,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
>
|
||||
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>
|
||||
<AddToCalendarButton />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal file
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||
|
||||
export function UpcomingEventsCarousel({ excludeId }: { excludeId: string }) {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getMeetups()
|
||||
.then((data: any) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
const now = new Date();
|
||||
const upcoming = list
|
||||
.filter((m: any) => {
|
||||
if (m.id === excludeId) return false;
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start >= now;
|
||||
})
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
|
||||
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
|
||||
);
|
||||
setItems(upcoming);
|
||||
})
|
||||
.catch(() => setItems([]));
|
||||
}, [excludeId]);
|
||||
|
||||
const scrollByDir = (dir: -1 | 1) => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const delta = Math.min(el.clientWidth * 0.85, 360);
|
||||
el.scrollBy({ left: dir * delta, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-16 pt-12 border-t border-zinc-800/50">
|
||||
<div className="flex items-end justify-between gap-4 mb-6">
|
||||
<h2 className="text-lg font-black tracking-tight">More upcoming events</h2>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByDir(-1)}
|
||||
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByDir(1)}
|
||||
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 -mx-1 px-1 [scrollbar-width:thin]"
|
||||
>
|
||||
{items.map((m) => (
|
||||
<div key={m.id} className="snap-start shrink-0 w-[min(280px,calc(100vw-5rem))] sm:w-[280px]">
|
||||
<MeetupCard meetup={m} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user