NGU-Web/src/pages/sections/EventList-Cards.tsx
Zaldimmar 1c8a0ac8b7 Fix the remaining type errors in src/
- Declare the --ig-fill custom property on React's CSSProperties once,
  instead of casting the style prop in Footer, Home and EventList-Cards.
- Type Footer's Get_In_Touch links as internal or external, so the
  external branch type-checks.
- Pass undefined rather than null for an absent gradient (Home) and
  Instagram URL (EventList-Cards).

tsc --noEmit is now clean, with and without --noImplicitAny.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:27:29 -05:00

798 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import {
splitByStatus,
typesPresent,
useEvents,
type EventFilter,
} from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
import type { EventListItem } from "../../lib/useContent.ts";
import type { SectionToggleProps } from "../../lib/sections.tsx";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
A band of event cards, as a peek carousel or a grid. Self
contained: give it a filter and it fetches, so the same section
appears three times on Retreats with a different `section` each
time, and could appear on a region's page with `host` instead.
<EventListCards section="national" view="carousel" />
<EventListCards host="northwest" view="grid" />
<EventListCards type={["class", "workshop"]} view="grid" />
`view` and `accent` come from the page's section manifest.
`type` pre-filters the band the way `section` and `host` do. Left
off, the band takes every kind it finds and grows a row of chips
to narrow by — but only once it holds more than one, so a band of
nothing but retreats shows no control at all.
═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
/* Last resort only. The API already falls back to the host
organization's logo when an event doesn't name its own, so this
fires only for an event with no host at all. */
const DEFAULT_ORG_LOGO = null;
/* An <img> that removes itself if the file 404s. */
function Logo({
file,
alt = "",
className,
}: {
file: string | null | undefined;
alt?: string;
className?: string;
}) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
return (
<img
src={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
/>
);
}
const TEAL = "#138ba0"; // last-resort card color
const CARD_BG = "#ffffff"; // sits under every card, gradient or not
/* ── Grid view ────────────────────────────────────────────────
The grid fits as many columns as the screen allows, with no
breakpoints: each card is at least CARD_MIN wide, and the
columns share whatever space is left over.
CARD_MIN narrowest a card may get before dropping a column
MAX_COLS ceiling on columns, so cards don't get absurd on
very wide monitors. GRID_MAX is derived from it.
───────────────────────────────────────────────────────────── */
const CARD_MIN = "32rem";
const MAX_COLS = 4;
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
/* Collapsed past-events strip: how much shows, and the downward
fade applied while it's collapsed. */
const PAST_PEEK = "10rem";
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
/* ── Carousel sizing ──────────────────────────────────────────
CARD the card itself
GAP space between cards — raise to push neighbors out
FADE_DIST how far past the card's edge neighbors fade to nothing
───────────────────────────────────────────────────────────── */
const CARD = "min(48rem, 90vw)";
const GAP = "5rem";
const SLIDE = `calc(${CARD} + ${GAP})`;
const HALF_SLIDE = `calc(${CARD} / 2)`;
const FADE_DIST = "18rem";
const EDGE_FADE = `linear-gradient(to right,
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
black calc(50% - ${HALF_SLIDE}),
black calc(50% + ${HALF_SLIDE}),
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
/* Card layout CSS lives in index.css under the .ev- prefix. */
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg
viewBox="0 0 24 24"
className="ig-icon w-6 h-6"
style={{ "--ig-fill": `url(#${id})` }}
>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<stop offset="75%" stopColor="#962FBF" />
<stop offset="100%" stopColor="#4F5BD5" />
</linearGradient>
</defs>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/* The title is the way into the event's own page.
A link on the title rather than a wrapper around the whole card:
the footer already holds anchors, and an anchor inside an anchor
is invalid markup that every browser resolves by guessing. The
carousel has the same constraint — it needs the card's click to
mean "bring this one to the front" on anything that isn't the
active slide. */
function TitleLink({
ev,
linked,
children,
}: {
ev: Pick<EventListItem, "id">;
linked: boolean;
children: ReactNode;
}) {
if (!linked) return <>{children}</>;
return (
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
{children}
</Link>
);
}
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel
compact=true → the grid card: details left, text right, and a
footer pinned to the bottom so every card in a
row is the same height with aligned buttons.
Exported because it takes an event and nothing else: a region's
page or a home strip can render one without the section around
it.
Fields arrive pre-resolved from the API — `color` is the event's
own or its host's, `status` is derived from the dates when it
isn't set — so nothing here reimplements those rules.
`linked` is the one thing a caller turns off: a card on the
event's own page shouldn't link to the page it's already on.
═══════════════════════════════════════════════════════════════ */
type CardProps = {
ev: EventListItem;
defaultColor?: string;
accent?: string;
compact?: boolean;
interactive?: boolean;
linked?: boolean;
showType?: boolean;
};
export function Card({
ev,
defaultColor = TEAL,
accent = TEAL,
compact = false,
interactive = true,
linked = true,
showType = false,
}: CardProps) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
const eventLogo = ev.event_logo;
const links = ev.links ?? [];
const igHandle = ev.instagram || null;
const igUrl = igHandle
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: undefined;
/* Off unless the caller says the band is mixed. A "Retreat" badge
on every card in a row of nothing but retreats is noise, and
the card can't tell on its own — it only ever sees one event. */
const typeBadge =
showType && ev.event_type ? (
<span
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
style={{ border: `1px solid ${color}`, color }}
>
{eventTypeLabel(ev.event_type)}
</span>
) : null;
/* An ordered array, so a card can carry one paragraph or five
without the component changing. */
const descriptions = (ev.description ?? []).map((text, i) => (
<p
key={i}
className={`leading-relaxed ${compact ? "text-base" : ""} ${
i > 0 ? "mt-2" : ""
}`}
>
{text}
</p>
));
return (
<div
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
compact ? "ev-card" : ""
}`}
style={{
border: `1px solid ${color}`,
background: ev.gradient ? `${ev.gradient}, ${CARD_BG}` : CARD_BG,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: interactive ? "auto" : "none",
}}
>
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
{compact ? (
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
{typeBadge}
<h3 className="text-3xl font-900 leading-tight">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
{ev.location_label && (
<p className="text-xl">{ev.location_label}</p>
)}
</div>
<Logo
file={eventLogo}
alt={ev.title}
className="ev-image w-full h-auto object-contain"
/>
<div className="ev-desc">{descriptions}</div>
</div>
) : (
/* ── FULL: details left, large event logo right ── */
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
{typeBadge}
<h3 className="text-4xl font-900">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
{ev.location_label && (
<p className="text-2xl">{ev.location_label}</p>
)}
</div>
<div className="md:col-span-1 flex items-center justify-end">
<Logo
file={eventLogo}
alt={ev.title}
className="w-full h-auto max-h-72 object-contain"
/>
</div>
</div>
{descriptions}
</>
)}
{/* Footer — mt-auto pins the whole block to the bottom so
buttons line up across every card in a grid row.
Three registration states, as before: links to follow, a
past event, or an announcement still to come. What's new
is that all three end in the same row, because every
event now has a page and a past one is often the more
worth reading — speakers, awards, what actually
happened. The notice is what changes; the way in
doesn't. */}
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
{links.length === 0 && (
<p className="text-center font-600" style={{ color: accent }}>
{past
? "This event has concluded — thank you to everyone who joined us!"
: igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
)}
<div
className={`flex flex-wrap justify-center items-center ${
compact ? "gap-2" : "gap-3"
} ${links.length === 0 ? "mt-4" : ""}`}
>
{links.map(item => (
<a
key={item.label}
href={item.url}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
{/* Only when there's nothing to register for and the
event hasn't happened — the same condition as before,
just no longer nested inside that branch. */}
{igHandle && !past && links.length === 0 && (
<a
href={igUrl}
target="_blank"
rel="noopener noreferrer"
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
compact ? "py-2.5 px-5" : "py-3 px-6"
}`}
style={{ border: `1px solid ${accent}`, color: accent }}
>
<InstagramIcon id={`ig-${ev.id}`} />
{igHandle}
</a>
)}
{/* Last, so Register reads first when there is one. */}
{linked && (
<Link
to={eventHref(ev.id)}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
Event details
</Link>
)}
</div>
</div>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
TYPE FILTER
Drawn only when a band actually holds more than one kind, so it
costs nothing today — every event is a retreat — and appears on
its own the first time a class or a workshop lands in that band.
Nothing on Retreats.tsx has to be reconfigured for it.
Chips rather than a select: with four or five options, all of
them visible is one tap, and the row reads as what the section
contains rather than as a form control.
═══════════════════════════════════════════════════════════════ */
type TypeFilterValue = EventType | "all";
type TypeFilterProps = {
types: typeof EVENT_TYPES;
active: TypeFilterValue;
setActive: (id: TypeFilterValue) => void;
accent: string;
};
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
const chip = (on: boolean) => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id: TypeFilterValue, label: string) => (
<button
key={id}
onClick={() => setActive(id)}
aria-pressed={active === id}
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
style={chip(active === id)}
>
{label}
</button>
);
return (
<div
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
style={{ maxWidth: GRID_MAX }}
role="group"
aria-label="Filter events by type"
>
{button("all", "All")}
{types.map(entry => button(entry.id, entry.plural))}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
TOGGLE — the control for the section heading's action bar
═══════════════════════════════════════════════════════════════ */
export function EventCardsToggle({
view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
return (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how events are displayed"
>
<button
onClick={() => setView("carousel")}
aria-pressed={view === "carousel"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "carousel")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="7" y="5" width="10" height="14" rx="2" />
<path d="M3.5 8v8M20.5 8v8" />
</svg>
Carousel
</button>
<button
onClick={() => setView("grid")}
aria-pressed={view === "grid"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "grid")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
Grid
</button>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION
Fetches its own slice of the events, then draws it as a carousel
or a grid.
An empty list means three different things now — still loading,
failed, or genuinely nothing scheduled — and they read very
differently to someone waiting, so they're distinguished rather
than all falling through to "coming soon".
═══════════════════════════════════════════════════════════════ */
type EventListCardsProps = EventFilter & {
/** "carousel" or "grid". */
view?: string;
accent?: string;
defaultColor?: string;
empty?: string;
};
export default function EventListCards({
section,
host,
status,
type,
view = "carousel",
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}: EventListCardsProps) {
const { events: fetched, loading, error } = useEvents({
section,
host,
status,
type,
});
const cardColor = defaultColor ?? accent;
const [index, setIndex] = useState(0);
const [showPast, setShowPast] = useState(false);
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
/* What this band holds, which is what the chips offer — not the
full list of declared types, three quarters of which would be
dead buttons. */
const availableTypes = useMemo(
() => typesPresent(fetched, EVENT_TYPES),
[fetched],
);
const mixed = availableTypes.length > 1;
const events = useMemo(
() =>
activeType === "all"
? fetched
: fetched.filter(e => e.event_type === activeType),
[fetched, activeType],
);
/* Open on the first upcoming event. The list is empty on the
first render, so this can't be a useState initialiser — it has
to wait for the data and then run once. Clearing the guard when
the list empties means a refetch re-seeds. */
const seeded = useRef(false);
/* Changing the chip is a different list, so the carousel re-seeds
on the first upcoming event of that kind rather than holding an
index that may now be past the end. Declared before the seed
effect so the guard is already clear when it runs. */
useEffect(() => {
seeded.current = false;
setIndex(0);
}, [activeType]);
useEffect(() => {
if (events.length === 0) {
seeded.current = false;
return;
}
if (seeded.current) return;
seeded.current = true;
const first = events.findIndex(e => e.status === "upcoming");
setIndex(first === -1 ? events.length - 1 : first);
}, [events]);
// Grid view splits the list; the carousel still shows everything.
const { upcoming, past: pastEvents } = splitByStatus(events);
const prev = () => setIndex(i => Math.max(0, i - 1));
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
// Arrows and dots follow the section accent, not the active card.
const arrowStyle = (enabled: boolean) => ({
border: `1px solid ${accent}`,
background: "rgba(255,255,255,0.85)",
color: enabled ? accent : "#b8c6c9",
cursor: enabled ? "pointer" : "default",
opacity: enabled ? 1 : 0.4,
});
const notice = (text: string) => (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
{text}
</p>
);
if (loading && events.length === 0) {
return (
<div className="overflow-hidden">
<div
className="mx-auto px-8 md:px-12 lg:px-16"
style={{ maxWidth: GRID_MAX }}
>
<div className="skeleton" role="status" aria-label="Loading events" />
</div>
</div>
);
}
if (error && events.length === 0) {
return (
<div className="overflow-hidden">
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
</div>
);
}
return (
<div className="overflow-hidden">
{mixed && (
<TypeFilter
types={availableTypes}
active={activeType}
setActive={setActiveType}
accent={accent}
/>
)}
{events.length === 0 ? (
/* Two different empties. Nothing scheduled is news; nothing
of the kind you just picked is a filter you can undo, and
the chips are still on screen to undo it with. */
notice(
activeType === "all"
? empty
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
)
) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
{upcoming.length > 0 && (
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{upcoming.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={cardColor}
accent={accent}
compact
showType={mixed}
/>
))}
</div>
)}
{pastEvents.length > 0 && (
<div className="mt-12">
<h3
className="text-2xl font-800 mb-6"
style={{ color: accent, opacity: 0.85 }}
>
Past Events
</h3>
{/* Collapsed: clipped to PAST_PEEK and faded out at the
bottom. Expanded: full height, no mask. */}
<div
className="relative transition-all duration-500 ease-out"
style={{
maxHeight: showPast ? "none" : PAST_PEEK,
overflow: showPast ? "visible" : "hidden",
maskImage: showPast ? "none" : PAST_FADE,
WebkitMaskImage: showPast ? "none" : PAST_FADE,
}}
>
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
aria-hidden={!showPast}
>
{pastEvents.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={cardColor}
accent={accent}
compact
interactive={showPast}
showType={mixed}
/>
))}
</div>
</div>
<div className="flex justify-center mt-6">
<button
onClick={() => setShowPast(v => !v)}
aria-expanded={showPast}
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{showPast
? "Hide past events"
: `See past events (${pastEvents.length})`}
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform duration-300"
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 9l6 6-6 6" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
/* ── CAROUSEL VIEW ── */
<>
<div className="relative mb-6">
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
<div
className="flex items-stretch transition-transform duration-500 ease-out"
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
>
{events.map((ev, i) => {
const active = i === index;
return (
<div
key={ev.id}
className="shrink-0"
style={{
width: SLIDE,
padding: `0 calc(${GAP} / 2)`,
cursor: active ? "default" : "pointer",
}}
onClick={() => !active && setIndex(i)}
aria-hidden={!active}
>
<Card
ev={ev}
defaultColor={cardColor}
accent={accent}
interactive={active}
showType={mixed}
/>
</div>
);
})}
</div>
</div>
{events.length > 1 && (
<>
<button
onClick={prev}
disabled={index === 0}
aria-label="Previous event"
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index > 0)}
>
‹
</button>
<button
onClick={next}
disabled={index === events.length - 1}
aria-label="Next event"
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index < events.length - 1)}
>
›
</button>
</>
)}
</div>
{events.length > 1 && (
<div className="flex justify-center gap-2">
{events.map((e, i) => (
<button
key={e.id}
onClick={() => setIndex(i)}
aria-label={`Go to ${e.title}`}
className="h-2.5 rounded-full transition-all duration-200"
style={{
width: i === index ? "1.5rem" : "0.625rem",
background: i === index ? accent : "#b8c6c9",
}}
/>
))}
</div>
)}
</>
)}
</div>
);
}