590 lines
23 KiB
TypeScript
590 lines
23 KiB
TypeScript
import { useState } from "react";
|
||
import PageShell from "../components/PageShell.tsx"
|
||
import eventsData from "../events.js";
|
||
|
||
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 => (file && LOGOS[file]) || null;
|
||
|
||
// Used when an event doesn't name its own org_logo.
|
||
const DEFAULT_ORG_LOGO = null;
|
||
|
||
/* An <img> that removes itself if the file 404s. */
|
||
function Logo({ file, alt = "", className }) {
|
||
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"; // page heading + last-resort card color
|
||
|
||
/* ── 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 (your original max-w-3xl)
|
||
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})))`;
|
||
|
||
/* ── Grid-card layout ─────────────────────────────────────────
|
||
Container queries, not media queries: each card reacts to its
|
||
OWN width, which is what we need now that the column count is
|
||
dynamic and the viewport says nothing about how wide a card is.
|
||
|
||
Wide card NGU logo + details on the left, event logo on the
|
||
right, descriptions full-width underneath.
|
||
Narrow card logos on their own row, then details, then
|
||
descriptions — three stacked rows.
|
||
───────────────────────────────────────────────────────────── */
|
||
const CARD_STYLES = `
|
||
.ev-card { container-type: inline-size; }
|
||
|
||
.ev-grid {
|
||
display: grid;
|
||
gap: 0.25rem 1.5rem;
|
||
grid-template-columns: 2fr 1fr;
|
||
grid-template-areas:
|
||
"ngu image"
|
||
"info image"
|
||
"desc desc";
|
||
}
|
||
|
||
@container (max-width: 30rem) {
|
||
.ev-grid {
|
||
grid-template-columns: 1fr;
|
||
grid-template-areas:
|
||
"ngu"
|
||
"info"
|
||
"desc";
|
||
}
|
||
.ev-grid .ev-image {
|
||
justify-self: start;
|
||
width: auto;
|
||
max-height: 7rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
}
|
||
|
||
.ev-ngu { grid-area: ngu; }
|
||
.ev-info { grid-area: info; }
|
||
.ev-image {
|
||
grid-area: image;
|
||
justify-self: stretch;
|
||
align-self: center;
|
||
max-height: 11rem;
|
||
}
|
||
.ev-desc { grid-area: desc; margin-top: 1rem; }
|
||
`;
|
||
|
||
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>
|
||
);
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
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.
|
||
═══════════════════════════════════════════════════════════════ */
|
||
export function Card({
|
||
ev,
|
||
defaultColor = TEAL,
|
||
accent = TEAL,
|
||
compact = false,
|
||
interactive = true,
|
||
}) {
|
||
const past = ev.status === "past";
|
||
const color = ev.color || defaultColor;
|
||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||
const eventLogo = ev.image;
|
||
const igHandle = ev.instagram || null;
|
||
const igUrl = igHandle
|
||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||
: null;
|
||
|
||
const descriptions = (
|
||
<>
|
||
{ev.desc_a && (
|
||
<p className={`mb-2 leading-relaxed ${compact ? "text-base" : ""}`}>
|
||
{ev.desc_a}
|
||
</p>
|
||
)}
|
||
{ev.desc_b && (
|
||
<p className={`leading-relaxed ${compact ? "text-base" : ""}`}>
|
||
{ev.desc_b}
|
||
</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 || undefined,
|
||
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">
|
||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||
{ev.theme && (
|
||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||
)}
|
||
{ev.date && <p className="text-xl">{ev.date}</p>}
|
||
{ev.location && <p className="text-xl">{ev.location}</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" />
|
||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||
{ev.theme && (
|
||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||
)}
|
||
{ev.date && <p className="text-2xl">{ev.date}</p>}
|
||
{ev.location && <p className="text-2xl">{ev.location}</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 it to the bottom so buttons line up
|
||
across every card in a grid row. */}
|
||
{ev.links.length > 0 ? (
|
||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||
{ev.links.map(item => (
|
||
<a
|
||
key={item.label}
|
||
href={item.link}
|
||
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>
|
||
))}
|
||
</div>
|
||
) : past ? (
|
||
<p
|
||
className={`mt-auto text-center font-600 ${compact ? "pt-8" : "pt-8"}`}
|
||
style={{ color: accent }}
|
||
>
|
||
This event has concluded — thank you to everyone who joined us!
|
||
</p>
|
||
) : (
|
||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||
<p className="text-center font-600" style={{ color: accent }}>
|
||
{igHandle
|
||
? "Registration has not opened yet, follow our instagram for more details."
|
||
: "Registration has not opened yet — check back soon for more details."}
|
||
</p>
|
||
{igHandle && (
|
||
<a
|
||
href={igUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||
}`}
|
||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||
>
|
||
<InstagramIcon id={`ig-${ev.id}`} />
|
||
{igHandle}
|
||
</a>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
VIEW TOGGLE — carousel / grid segmented control
|
||
═══════════════════════════════════════════════════════════════ */
|
||
function ViewToggle({ view, setView, accent }) {
|
||
const btn = active => ({
|
||
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 — heading + toggle, then carousel or grid
|
||
═══════════════════════════════════════════════════════════════ */
|
||
export function CardSection({ section, view }) {
|
||
|
||
const {
|
||
events,
|
||
accent,
|
||
defaultColor,
|
||
} = section;
|
||
|
||
const startIndex = (() => {
|
||
const i = events.findIndex(e => e.status === "upcoming");
|
||
return i === -1 ? Math.max(0, events.length - 1) : i;
|
||
})();
|
||
|
||
const [index, setIndex] = useState(startIndex);
|
||
const [showPast, setShowPast] = useState(false);
|
||
|
||
// Grid view splits the list; the carousel still shows everything.
|
||
const upcoming = events.filter(e => e.status !== "past");
|
||
const pastEvents = events.filter(e => e.status === "past");
|
||
|
||
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 => ({
|
||
border: `1px solid ${accent}`,
|
||
background: "rgba(255,255,255,0.85)",
|
||
color: enabled ? accent : "#b8c6c9",
|
||
cursor: enabled ? "pointer" : "default",
|
||
opacity: enabled ? 1 : 0.4,
|
||
});
|
||
|
||
return (
|
||
<div className="overflow-hidden">
|
||
{events.length === 0 ? (
|
||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||
· Events coming soon, stay connected for announcements ·
|
||
</p>
|
||
) : 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={defaultColor}
|
||
accent={accent}
|
||
compact
|
||
/>
|
||
))}
|
||
</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={defaultColor}
|
||
accent={accent}
|
||
compact
|
||
interactive={showPast}
|
||
/>
|
||
))}
|
||
</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={defaultColor}
|
||
accent={accent}
|
||
interactive={active}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
RETREATS PAGE — hands the card sections to the shared PageShell
|
||
so this page matches Community, Leadership and Resources.
|
||
|
||
View state lives here, one entry per section, so the toggle in a
|
||
section's action bar and the cards below it stay in sync.
|
||
═══════════════════════════════════════════════════════════════ */
|
||
export default function RetreatsPage() {
|
||
const [views, setViews] = useState(() =>
|
||
Object.fromEntries(
|
||
eventsData.sections.map(s => [s.id, s.defaultView || "carousel"])
|
||
)
|
||
);
|
||
|
||
const setView = (id, value) =>
|
||
setViews(prev => ({ ...prev, [id]: value }));
|
||
|
||
const shellSections = eventsData.sections.map(section => {
|
||
const view = views[section.id];
|
||
return {
|
||
id: section.id,
|
||
title: section.title,
|
||
blurb: section.blurb,
|
||
accent: section.accent,
|
||
background: section.background,
|
||
actions: section.events.length > 0 && (
|
||
<ViewToggle
|
||
view={view}
|
||
setView={value => setView(section.id, value)}
|
||
accent={section.accent}
|
||
/>
|
||
),
|
||
content: <CardSection section={section} view={view} />,
|
||
};
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<style>{CARD_STYLES}</style>
|
||
<PageShell
|
||
title="Retreats"
|
||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
||
sections={shellSections}
|
||
/>
|
||
</>
|
||
);
|
||
}
|