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. `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("../../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 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 ( {alt} 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" }) => ( ); /* 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; linked: boolean; children: ReactNode; }) { if (!linked) return <>{children}; return ( {children} ); } /* ═══════════════════════════════════════════════════════════════ 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 ? ( {eventTypeLabel(ev.event_type)} ) : null; /* An ordered array, so a card can carry one paragraph or five without the component changing. */ const descriptions = (ev.description ?? []).map((text, i) => (

0 ? "mt-2" : "" }`} > {text}

)); return (
{compact ? ( /* ── GRID CARD: layout driven by .ev-grid container queries ── */
{typeBadge}

{ev.title}

{ev.theme && (

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} {ev.location_label && (

{ev.location_label}

)}
{descriptions}
) : ( /* ── FULL: details left, large event logo right ── */ <>
{typeBadge}

{ev.title}

{ev.theme && (

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} {ev.location_label && (

{ev.location_label}

)}
{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. */}
{links.length === 0 && (

{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."}

)}
{links.map(item => ( {item.label} ))} {/* 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 && ( {igHandle} )} {/* Last, so Register reads first when there is one. */} {linked && ( Event details )}
); } /* ═══════════════════════════════════════════════════════════════ 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) => ( ); return (
{button("all", "All")} {types.map(entry => button(entry.id, entry.plural))}
); } /* ═══════════════════════════════════════════════════════════════ TOGGLE — the control for the section heading's action bar ═══════════════════════════════════════════════════════════════ */ export function EventCardsToggle({ view, setView, accent, }: Pick) { const btn = (active: boolean) => ({ background: active ? accent : "transparent", color: active ? "#ffffff" : accent, }); return (
); } /* ═══════════════════════════════════════════════════════════════ 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("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) => (

{text}

); if (loading && events.length === 0) { return (
); } if (error && events.length === 0) { return (
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
); } return (
{mixed && ( )} {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 ── */
{upcoming.length > 0 && (
{upcoming.map(ev => ( ))}
)} {pastEvents.length > 0 && (

Past Events

{/* Collapsed: clipped to PAST_PEEK and faded out at the bottom. Expanded: full height, no mask. */}
{pastEvents.map(ev => ( ))}
)}
) : ( /* ── CAROUSEL VIEW ── */ <>
{events.map((ev, i) => { const active = i === index; return (
!active && setIndex(i)} aria-hidden={!active} >
); })}
{events.length > 1 && ( <> )}
{events.length > 1 && (
{events.map((e, i) => (
)} )}
); }