v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
30
src/components/ArrowLink.tsx
Normal file
30
src/components/ArrowLink.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Link } from "react-router-dom";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ARROW LINK
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`${size} shrink-0 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SITE_BANNER } from "../bannerConfig.js";
|
||||
import { SITE_BANNER } from "../data/bannerConfig.js";
|
||||
|
||||
export default function Banner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
|
|
|||
365
src/components/PeopleTiles.css
Normal file
365
src/components/PeopleTiles.css
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/* PeopleTiles — polaroid-style people list.
|
||||
Everything is scoped under .pl and driven by custom properties, so a section
|
||||
can retune it inline: <PeopleTiles style={{ "--pl-base-w": "150px" }} /> */
|
||||
|
||||
.pl {
|
||||
/* geometry */
|
||||
--pl-scale: 1;
|
||||
--pl-base-w: 132px;
|
||||
--pl-w: calc(var(--pl-base-w) * var(--pl-scale));
|
||||
--pl-gap: calc(0.875rem * var(--pl-scale));
|
||||
--pl-pad: calc(0.5rem * var(--pl-scale));
|
||||
--pl-photo-ratio: 1;
|
||||
--pl-radius: 3px;
|
||||
|
||||
/* color */
|
||||
--pl-accent: #138ba0;
|
||||
--pl-frame-bg: #fff;
|
||||
--pl-frame-edge: rgba(15, 23, 42, 0.1);
|
||||
--pl-photo-bg: #e7edef;
|
||||
--pl-ink: #16262b;
|
||||
--pl-muted: #4a6b72;
|
||||
--pl-rule: rgba(45, 200, 224, 0.3);
|
||||
--pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5);
|
||||
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-gap) * 1.25);
|
||||
color: var(--pl-ink);
|
||||
}
|
||||
|
||||
.pl[data-size="sm"] {
|
||||
--pl-base-w: 104px;
|
||||
--pl-radius: 2px;
|
||||
}
|
||||
|
||||
.pl[data-size="lg"] {
|
||||
--pl-base-w: 164px;
|
||||
--pl-photo-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
/* groups --------------------------------------------------------------- */
|
||||
|
||||
.pl__groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: calc(var(--pl-gap) * 1.5);
|
||||
}
|
||||
|
||||
.pl[data-align="center"] .pl__groups,
|
||||
.pl[data-align="center"] .pl__row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pl__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-gap) * 0.75);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl__group + .pl__group {
|
||||
padding-left: calc(var(--pl-gap) * 1.5);
|
||||
border-left: 1px solid var(--pl-rule);
|
||||
}
|
||||
|
||||
.pl__group-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pl__group-label {
|
||||
margin: 0;
|
||||
font-size: calc(0.9375rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.pl__group-note {
|
||||
margin: 0;
|
||||
font-size: calc(0.8125rem * var(--pl-scale));
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--pl-gap);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pl[data-overflow="scroll"] .pl__row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 0.35rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.pl[data-overflow="scroll"] .pl__item {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.pl__item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* tile ----------------------------------------------------------------- */
|
||||
|
||||
.pl__tile {
|
||||
display: block;
|
||||
width: var(--pl-w);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.pl__tile--button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pl__frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-pad) * 0.9);
|
||||
padding: var(--pl-pad);
|
||||
padding-bottom: calc(var(--pl-pad) * 1.6);
|
||||
background: var(--pl-frame-bg);
|
||||
border: 1px solid var(--pl-frame-edge);
|
||||
border-radius: var(--pl-radius);
|
||||
box-shadow: var(--pl-shadow);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame {
|
||||
transform: rotate(-1.1deg);
|
||||
}
|
||||
|
||||
.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame {
|
||||
transform: rotate(0.9deg);
|
||||
}
|
||||
|
||||
.pl__tile--button:hover .pl__frame,
|
||||
.pl__tile--button:focus-visible .pl__frame {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.pl__tile--button:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pl__tile--button:focus-visible .pl__frame {
|
||||
outline: 2px solid var(--pl-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.pl__tile[aria-expanded="true"] .pl__frame {
|
||||
border-color: var(--pl-accent);
|
||||
box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.pl__photo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: var(--pl-photo-ratio);
|
||||
overflow: hidden;
|
||||
background: var(--pl-photo-bg);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.pl__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pl__initials {
|
||||
font-size: calc(1.25rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0 calc(var(--pl-pad) * 0.25);
|
||||
min-height: calc(1.1rem * var(--pl-scale));
|
||||
}
|
||||
|
||||
.pl__name {
|
||||
font-size: calc(0.875rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.pl__title {
|
||||
font-size: calc(0.75rem * var(--pl-scale));
|
||||
line-height: 1.3;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__badge {
|
||||
position: absolute;
|
||||
right: calc(var(--pl-pad) * 0.6);
|
||||
bottom: calc(var(--pl-pad) * 0.6);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(1.25rem * var(--pl-scale));
|
||||
height: calc(1.25rem * var(--pl-scale));
|
||||
border-radius: 999px;
|
||||
background: var(--pl-accent);
|
||||
color: #fff;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.pl__tile[aria-expanded="true"] .pl__badge {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* bio panel ------------------------------------------------------------ */
|
||||
|
||||
.pl__bio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-left: 3px solid var(--pl-accent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--pl-accent) 7%, #fff);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.pl__bio-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pl__bio-name {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pl__bio-title {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 1px solid var(--pl-frame-edge);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--pl-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pl__close:hover {
|
||||
color: var(--pl-ink);
|
||||
border-color: var(--pl-accent);
|
||||
}
|
||||
|
||||
.pl__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pl__fact dt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__fact dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pl__fact a {
|
||||
color: var(--pl-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.pl__bio-text {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.pl__empty {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* narrow layouts ------------------------------------------------------- */
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.pl__group + .pl__group {
|
||||
padding-left: 0;
|
||||
padding-top: calc(var(--pl-gap) * 1.1);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--pl-rule);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (container-type: inline-size) {
|
||||
@media (max-width: 640px) {
|
||||
.pl__group + .pl__group {
|
||||
padding-left: 0;
|
||||
padding-top: calc(var(--pl-gap) * 1.1);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--pl-rule);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pl__frame,
|
||||
.pl__badge {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pl__tile--button:hover .pl__frame,
|
||||
.pl__tile--button:focus-visible .pl__frame {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
375
src/components/PeopleTiles.tsx
Normal file
375
src/components/PeopleTiles.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import "./PeopleTiles.css";
|
||||
|
||||
/**
|
||||
* PeopleTiles — a horizontal, polaroid-style people list.
|
||||
*
|
||||
* Drop into any section:
|
||||
* <PeopleTiles size="md" groups={staffGroups} />
|
||||
* <PeopleTiles size="sm" people={volunteers} />
|
||||
*
|
||||
* Sizes
|
||||
* sm photo + name
|
||||
* md photo + name + title
|
||||
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
|
||||
*
|
||||
* Group shape
|
||||
* { id, label?, note?, accent?, people: [person] }
|
||||
*
|
||||
* Person shape
|
||||
* {
|
||||
* id, name,
|
||||
* title?, // "Regional Director"
|
||||
* photo?, // "/people/jane-doe.jpg" — falls back to initials
|
||||
* pronouns?, // "she/her"
|
||||
* age?, // number, or use birthdate
|
||||
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
|
||||
* org?, // "Grace Chapel" or { name, href }
|
||||
* bio?, // string or string[] (paragraphs)
|
||||
* accent?, // per-person override
|
||||
* }
|
||||
*/
|
||||
|
||||
const SIZE_FEATURES = {
|
||||
sm: { title: false, bio: false },
|
||||
md: { title: true, bio: false },
|
||||
lg: { title: true, bio: true },
|
||||
};
|
||||
|
||||
export default function PeopleTiles({
|
||||
people,
|
||||
groups,
|
||||
size = "md",
|
||||
scale = 1,
|
||||
overflow = "wrap", // "wrap" | "scroll"
|
||||
align = "start", // "start" | "center"
|
||||
accent,
|
||||
tilt = false,
|
||||
emptyMessage = "No one listed yet.",
|
||||
onExpand,
|
||||
className = "",
|
||||
style,
|
||||
...rest
|
||||
}) {
|
||||
const baseId = useId().replace(/:/g, "");
|
||||
const [openKey, setOpenKey] = useState(null);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
|
||||
const features = SIZE_FEATURES[resolvedSize];
|
||||
|
||||
const resolvedGroups = useMemo(() => {
|
||||
const source = Array.isArray(groups) && groups.length
|
||||
? groups
|
||||
: Array.isArray(people) && people.length
|
||||
? [{ id: "all", people }]
|
||||
: [];
|
||||
|
||||
return source
|
||||
.map((group, groupIndex) => ({
|
||||
...group,
|
||||
id: group.id ?? `group-${groupIndex}`,
|
||||
people: (group.people || []).filter(Boolean),
|
||||
}))
|
||||
.filter((group) => group.people.length > 0);
|
||||
}, [groups, people]);
|
||||
|
||||
// Close the bio if the person it belongs to disappears from the data.
|
||||
useEffect(() => {
|
||||
if (!openKey) return;
|
||||
const stillThere = resolvedGroups.some((group) =>
|
||||
group.people.some((person, index) => keyFor(group, person, index) === openKey),
|
||||
);
|
||||
if (!stillThere) setOpenKey(null);
|
||||
}, [openKey, resolvedGroups]);
|
||||
|
||||
if (!resolvedGroups.length) {
|
||||
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
|
||||
}
|
||||
|
||||
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
|
||||
|
||||
function toggle(group, person, index) {
|
||||
const key = keyFor(group, person, index);
|
||||
const next = openKey === key ? null : key;
|
||||
setOpenKey(next);
|
||||
if (onExpand) onExpand(next ? person : null, next ? group : null);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape" && openKey) {
|
||||
event.stopPropagation();
|
||||
setOpenKey(null);
|
||||
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
|
||||
if (button) button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`pl ${className}`.trim()}
|
||||
data-size={resolvedSize}
|
||||
data-overflow={overflow}
|
||||
data-align={align}
|
||||
data-tilt={tilt ? "on" : "off"}
|
||||
style={{
|
||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||
...(accent ? { "--pl-accent": accent } : null),
|
||||
...style,
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
<div className="pl__groups">
|
||||
{resolvedGroups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
className="pl__group"
|
||||
style={group.accent ? { "--pl-accent": group.accent } : undefined}
|
||||
aria-label={group.label || undefined}
|
||||
>
|
||||
{(group.label || group.note) && (
|
||||
<header className="pl__group-head">
|
||||
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
|
||||
{group.note && <p className="pl__group-note">{group.note}</p>}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<ul className="pl__row">
|
||||
{group.people.map((person, index) => {
|
||||
const key = keyFor(group, person, index);
|
||||
const expandable = features.bio && hasBio(person);
|
||||
const isOpen = expandable && openKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="pl__item"
|
||||
style={person.accent ? { "--pl-accent": person.accent } : undefined}
|
||||
>
|
||||
<Tile
|
||||
person={person}
|
||||
showTitle={features.title}
|
||||
expandable={expandable}
|
||||
isOpen={isOpen}
|
||||
panelId={`${baseId}-bio`}
|
||||
onToggle={() => toggle(group, person, index)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<BioPanel
|
||||
id={`${baseId}-bio`}
|
||||
person={open.person}
|
||||
group={open.group}
|
||||
onClose={() => {
|
||||
setOpenKey(null);
|
||||
if (onExpand) onExpand(null, null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="pl__frame">
|
||||
<span className="pl__photo">
|
||||
<Photo src={person.photo} name={person.name} />
|
||||
</span>
|
||||
<span className="pl__caption">
|
||||
<span className="pl__name">{person.name}</span>
|
||||
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
|
||||
</span>
|
||||
{expandable && (
|
||||
<span className="pl__badge" aria-hidden="true">
|
||||
<Chevron />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!expandable) {
|
||||
return <div className="pl__tile">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pl__tile pl__tile--button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{content}
|
||||
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ src, name }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<span className="pl__initials" aria-hidden="true">
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="pl__img"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BioPanel({ id, person, group, onClose }) {
|
||||
const age = resolveAge(person);
|
||||
const org = resolveOrg(person.org);
|
||||
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="pl__bio"
|
||||
role="region"
|
||||
aria-label={`About ${person.name}`}
|
||||
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
|
||||
>
|
||||
<div className="pl__bio-head">
|
||||
<div>
|
||||
<p className="pl__bio-name">{person.name}</p>
|
||||
{person.title && <p className="pl__bio-title">{person.title}</p>}
|
||||
</div>
|
||||
<button type="button" className="pl__close" onClick={onClose}>
|
||||
<span className="pl__sr">Close bio</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(person.pronouns || age != null || org) && (
|
||||
<dl className="pl__facts">
|
||||
{person.pronouns && (
|
||||
<div className="pl__fact">
|
||||
<dt>Pronouns</dt>
|
||||
<dd>{person.pronouns}</dd>
|
||||
</div>
|
||||
)}
|
||||
{age != null && (
|
||||
<div className="pl__fact">
|
||||
<dt>Age</dt>
|
||||
<dd>{age}</dd>
|
||||
</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="pl__fact">
|
||||
<dt>Home organization</dt>
|
||||
<dd>
|
||||
{org.href ? (
|
||||
<a href={org.href} target="_blank" rel="noreferrer">
|
||||
{org.name}
|
||||
</a>
|
||||
) : (
|
||||
org.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
||||
<p key={index} className="pl__bio-text">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
|
||||
<path
|
||||
d="M4 6.5 8 10.5 12 6.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* helpers ------------------------------------------------------------- */
|
||||
|
||||
function keyFor(group, person, index) {
|
||||
return `${group.id}:${person.id ?? person.name ?? index}`;
|
||||
}
|
||||
|
||||
function findByKey(groups, key) {
|
||||
if (!key) return null;
|
||||
for (const group of groups) {
|
||||
for (let index = 0; index < group.people.length; index += 1) {
|
||||
const person = group.people[index];
|
||||
if (keyFor(group, person, index) === key) return { group, person };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBio(person) {
|
||||
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
|
||||
return Boolean(person.bio);
|
||||
}
|
||||
|
||||
function initials(name = "") {
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0] || "")
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function resolveAge(person) {
|
||||
if (typeof person.age === "number") return person.age;
|
||||
if (!person.birthdate) return null;
|
||||
const born = new Date(person.birthdate);
|
||||
if (Number.isNaN(born.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - born.getFullYear();
|
||||
const monthDelta = now.getMonth() - born.getMonth();
|
||||
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
function resolveOrg(org) {
|
||||
if (!org) return null;
|
||||
if (typeof org === "string") return { name: org };
|
||||
if (!org.name) return null;
|
||||
return org;
|
||||
}
|
||||
146
src/data/chapters.js
Normal file
146
src/data/chapters.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
LOCAL CHAPTER DATA
|
||||
|
||||
The chapter map's view of the organization list. Regions and
|
||||
chapters are the same table and the same endpoint; this hook
|
||||
reshapes them into what a map needs — slices per tile, counts
|
||||
per tile, chapters grouped by region.
|
||||
|
||||
Its return shape is unchanged from the version that called
|
||||
/regions and /chapters, so LocalChapters didn't have to move.
|
||||
|
||||
Two things live elsewhere:
|
||||
|
||||
the tile grid src/data/mapGrid.js. Where a state sits never
|
||||
changes, so it isn't worth a round trip.
|
||||
|
||||
the fetch src/data/organizations.js. One endpoint for
|
||||
every kind, so a section listing regions and
|
||||
a section listing partners read alike.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
useOrganizations,
|
||||
} from "./organizations.js";
|
||||
import {
|
||||
areaForChapter,
|
||||
buildAreaSlices,
|
||||
countChaptersByArea,
|
||||
} from "./mapGrid.js";
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export { initialsFor };
|
||||
|
||||
export function useCommunity() {
|
||||
const regionsQuery = useOrganizations("region");
|
||||
const chaptersQuery = useOrganizations("chapter");
|
||||
|
||||
const rawRegions = regionsQuery.organizations;
|
||||
const rawChapters = chaptersQuery.organizations;
|
||||
|
||||
return useMemo(() => {
|
||||
/* ── Regions ───────────────────────────────────────────────
|
||||
scope and map_note are lifted out of `details` so the rest
|
||||
of the app doesn't have to know they're kind-specific. */
|
||||
const regions = rawRegions.map((org) => ({
|
||||
...org,
|
||||
scope: org.details?.scope ?? null,
|
||||
map_note: org.details?.map_note ?? null,
|
||||
areas: org.details?.areas ?? [],
|
||||
}));
|
||||
|
||||
// Flat, because buildAreaSlices takes it that way.
|
||||
const regionAreas = regions.flatMap((region) =>
|
||||
region.areas.map((area) => ({ ...area, region_id: region.id })),
|
||||
);
|
||||
|
||||
/* ── Chapters, each tagged with the tile it lights up ──────
|
||||
The database stores a real address; which square that maps
|
||||
to is a rendering question, answered here once rather than
|
||||
at every call site. */
|
||||
const chapters = rawChapters.map((org) => ({
|
||||
...org,
|
||||
region_id: org.details?.region_id ?? null,
|
||||
region_name: org.details?.region_name ?? null,
|
||||
region_color: org.details?.region_color ?? null,
|
||||
meets: org.details?.meets ?? null,
|
||||
started: org.details?.started ?? null,
|
||||
area_code: areaForChapter(org),
|
||||
}));
|
||||
|
||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||
|
||||
/* { WA: [{ regionId, name, color, share, edge, note }], ... }
|
||||
California comes back as two half slices rather than a
|
||||
primary and a remainder, so the SVG never does arithmetic. */
|
||||
const slices = buildAreaSlices(regionAreas, regions);
|
||||
|
||||
const chapterCounts = countChaptersByArea(chapters);
|
||||
|
||||
const chaptersByRegion = new Map();
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.region_id) continue;
|
||||
const list = chaptersByRegion.get(chapter.region_id);
|
||||
if (list) list.push(chapter);
|
||||
else chaptersByRegion.set(chapter.region_id, [chapter]);
|
||||
}
|
||||
|
||||
const chaptersIn = (regionId) => chaptersByRegion.get(regionId) ?? [];
|
||||
|
||||
/* Sorted by name to match how the page has always shown them,
|
||||
rather than by the sort_order the API returns. */
|
||||
const domestic = regions.filter((r) => r.scope === "domestic").sort(byName);
|
||||
const international = regions
|
||||
.filter((r) => r.scope === "international")
|
||||
.sort(byName);
|
||||
const virtual = regions.filter((r) => r.scope === "virtual");
|
||||
|
||||
const areasLabelFor = (regionId) =>
|
||||
areasSentence(regionById[regionId]?.areas ?? []);
|
||||
|
||||
const subtextFor = (region) => {
|
||||
const label = areasLabelFor(region.id);
|
||||
if (label && region.map_note) return `${label} · ${region.map_note}`;
|
||||
return label || region.map_note || "";
|
||||
};
|
||||
|
||||
const regionsForArea = (areaCode) =>
|
||||
(slices[areaCode] ?? [])
|
||||
.map((slice) => regionById[slice.regionId])
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
loading: regionsQuery.loading || chaptersQuery.loading,
|
||||
error: regionsQuery.error ?? chaptersQuery.error,
|
||||
|
||||
regions,
|
||||
regionAreas,
|
||||
regionById,
|
||||
domestic,
|
||||
international,
|
||||
virtual,
|
||||
|
||||
chapters,
|
||||
chaptersIn,
|
||||
|
||||
slices,
|
||||
chapterCounts,
|
||||
regionsForArea,
|
||||
|
||||
areasLabelFor,
|
||||
subtextFor,
|
||||
};
|
||||
}, [
|
||||
rawRegions,
|
||||
rawChapters,
|
||||
regionsQuery.loading,
|
||||
regionsQuery.error,
|
||||
chaptersQuery.loading,
|
||||
chaptersQuery.error,
|
||||
]);
|
||||
}
|
||||
54
src/data/eventData.js
Normal file
54
src/data/eventData.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT DATA
|
||||
|
||||
One request, filtered per section. The Retreats page has three
|
||||
bands of events, and all three call this hook — the cache in
|
||||
api.js keys on the path, so they share a single fetch and each
|
||||
narrows the result to what it shows.
|
||||
|
||||
useEvents({ section: "national" }) one band
|
||||
useEvents({ host: "northwest" }) a region's own events
|
||||
useEvents({ status: "upcoming" }) a home page strip
|
||||
useEvents() everything
|
||||
|
||||
Filtering here rather than in the query keeps the endpoint to
|
||||
one cached response. At a few dozen events that's the right
|
||||
trade; if the list ever runs to hundreds, move the filters into
|
||||
the URL and let each become its own cache entry.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useResource } from "../lib/useResource.js";
|
||||
|
||||
const EMPTY = { events: [] };
|
||||
|
||||
export function useEvents({ section, host, status } = {}) {
|
||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
||||
|
||||
const all = data?.events;
|
||||
|
||||
const events = useMemo(() => {
|
||||
let list = all ?? [];
|
||||
if (section) list = list.filter(e => e.section_id === section);
|
||||
if (host) list = list.filter(e => e.host?.id === host);
|
||||
if (status) list = list.filter(e => e.status === status);
|
||||
return list;
|
||||
}, [all, section, host, status]);
|
||||
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||
explicit value when there is one, otherwise derived from ends_on
|
||||
— so nothing here needs to know which of the two it got. */
|
||||
export function splitByStatus(events = []) {
|
||||
const upcoming = [];
|
||||
const past = [];
|
||||
|
||||
for (const event of events) {
|
||||
(event.status === "past" ? past : upcoming).push(event);
|
||||
}
|
||||
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ const eventsData = {
|
|||
background: "#eef9fb",
|
||||
events: [
|
||||
{
|
||||
id: "partner-example",
|
||||
id: "partner-example-1",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
|
|
@ -206,7 +206,7 @@ const eventsData = {
|
|||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example",
|
||||
id: "partner-example-2",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
|
|
@ -227,7 +227,7 @@ const eventsData = {
|
|||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example",
|
||||
id: "partner-example-3",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
|
|
@ -248,7 +248,7 @@ const eventsData = {
|
|||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example",
|
||||
id: "partner-example-4",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
|
|
@ -269,7 +269,7 @@ const eventsData = {
|
|||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example",
|
||||
id: "partner-example-5",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
174
src/data/mapGrid.js
Normal file
174
src/data/mapGrid.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
MAP GRID
|
||||
|
||||
Pure layout. Where each tile sits, what it's called, and how to
|
||||
work out which tile a chapter belongs to. None of this is in the
|
||||
database because none of it changes — Rhode Island will not be
|
||||
moving, and no admin form should offer to move it.
|
||||
|
||||
What IS in the database is which regions cover which areas, and
|
||||
how much of each. That arrives as region_areas rows whose
|
||||
area_code matches a key in AREAS below. A code with no match
|
||||
here simply doesn't paint, which is how Africa and the UK exist
|
||||
as regions with no tile.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const GRID_COLS = 13;
|
||||
export const GRID_ROWS = 7;
|
||||
|
||||
/* ── States ────────────────────────────────────────────────────
|
||||
[column, row], both 1-based. A tile grid rather than true
|
||||
geography: every state reads at the same size, it stays
|
||||
legible on a phone, and there's no map library to load.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const STATE_GRID = {
|
||||
AK: [1, 1], ME: [13, 1],
|
||||
WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
|
||||
MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
|
||||
OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
|
||||
IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
|
||||
CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
|
||||
WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
|
||||
AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
|
||||
DC: [9, 5], RI: [12, 3],
|
||||
OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
|
||||
HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
|
||||
};
|
||||
|
||||
/* ── Bands ─────────────────────────────────────────────────────
|
||||
Wide areas that aren't states. A band is just a tile with a
|
||||
span, which keeps the renderer from needing a second code path.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const BAND_GRID = {
|
||||
CANADA: [3, 1, 8], // col, row, span
|
||||
};
|
||||
|
||||
export const AREA_NAMES = {
|
||||
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
|
||||
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
|
||||
DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
|
||||
ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
|
||||
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
|
||||
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
|
||||
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
||||
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
||||
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
|
||||
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
|
||||
SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
|
||||
UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
|
||||
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
|
||||
CANADA: "Canada",
|
||||
};
|
||||
|
||||
/* ── One list the renderer walks ───────────────────────────────
|
||||
States and bands unified, so drawing the map is a single map()
|
||||
over AREAS rather than two loops with different shapes.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const AREAS = Object.freeze([
|
||||
...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({
|
||||
code,
|
||||
name: AREA_NAMES[code] ?? code,
|
||||
col,
|
||||
row,
|
||||
span: 1,
|
||||
isState: true,
|
||||
})),
|
||||
...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({
|
||||
code,
|
||||
name: AREA_NAMES[code] ?? code,
|
||||
col,
|
||||
row,
|
||||
span,
|
||||
isState: false,
|
||||
})),
|
||||
]);
|
||||
|
||||
export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a]));
|
||||
|
||||
/* ── Which tile a chapter sits on ──────────────────────────────
|
||||
The database stores a real address — state_code for US, and a
|
||||
country code otherwise — rather than a tile name. This is the
|
||||
one place that translates between the two, so adding a Mexico
|
||||
band later means a line here and nothing in SQL.
|
||||
|
||||
Returns null for anything with no tile, which covers virtual
|
||||
chapters and any country not drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const COUNTRY_AREA = {
|
||||
CA: "CANADA", // ISO country code, not California
|
||||
};
|
||||
|
||||
export function areaForChapter(chapter) {
|
||||
if (!chapter) return null;
|
||||
if (chapter.is_online) return null;
|
||||
|
||||
if (chapter.country === "US") {
|
||||
return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null;
|
||||
}
|
||||
return COUNTRY_AREA[chapter.country] ?? null;
|
||||
}
|
||||
|
||||
/* ── Slices per tile ───────────────────────────────────────────
|
||||
Turns region_areas rows into what the SVG needs: for each
|
||||
tile, the regions painting it and the fraction each takes.
|
||||
|
||||
A region with share 1 and no edge fills the tile. A shared
|
||||
tile has one row per region, each declaring its own slice, so
|
||||
California is two entries of 0.5 rather than a primary plus a
|
||||
remainder — no arithmetic, and the renderer doesn't need to
|
||||
know which region "really" owns it.
|
||||
|
||||
regionAreas [{ region_id, area_code, share, edge, note }]
|
||||
regions [{ id, name, color, ... }]
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function buildAreaSlices(regionAreas = [], regions = []) {
|
||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||
const byArea = {};
|
||||
|
||||
for (const row of regionAreas) {
|
||||
const area = AREA_BY_CODE[row.area_code];
|
||||
const region = regionById[row.region_id];
|
||||
if (!area || !region) continue; // untiled region, or unknown code
|
||||
|
||||
(byArea[row.area_code] ??= []).push({
|
||||
regionId: region.id,
|
||||
name: region.name,
|
||||
color: region.color,
|
||||
share: row.share ?? 1,
|
||||
edge: row.edge ?? null,
|
||||
note: row.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Full-tile slice first, so a partial slice paints over it.
|
||||
for (const slices of Object.values(byArea)) {
|
||||
slices.sort((a, b) => b.share - a.share);
|
||||
}
|
||||
|
||||
return byArea;
|
||||
}
|
||||
|
||||
/* ── Chapter counts per tile ───────────────────────────────────
|
||||
{ WA: 2, MO: 1, CANADA: 1 }
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function countChaptersByArea(chapters = []) {
|
||||
const counts = {};
|
||||
for (const chapter of chapters) {
|
||||
const code = areaForChapter(chapter);
|
||||
if (code) counts[code] = (counts[code] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
|
||||
Replaces statesLabel. Note the annotation now comes straight
|
||||
from the row rather than being looked up in a splits table and
|
||||
branched on whether this region is the primary.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function areasLabel(regionId, regionAreas = []) {
|
||||
return regionAreas
|
||||
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
||||
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
90
src/data/organizations.js
Normal file
90
src/data/organizations.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATIONS
|
||||
|
||||
Regions, chapters, partners and NGU itself are one table and one
|
||||
endpoint. Anything that lists organizations reads them the same
|
||||
way and filters by kind:
|
||||
|
||||
const { organizations } = useOrganizations("region");
|
||||
const { organizations } = useOrganizations("partner");
|
||||
const { organizations } = useOrganizations(); // all
|
||||
|
||||
Every organization has the same card surface — name, colour,
|
||||
logo, description, links. What differs by kind sits under
|
||||
`details`, so a list component can render the common parts
|
||||
without knowing what it's holding:
|
||||
|
||||
region { scope, map_note, areas[], chapters[] }
|
||||
chapter { region_id, region_name, region_color, meets, started }
|
||||
partner {}
|
||||
|
||||
Each kind is a separate request path, so the cache in api.js
|
||||
keys them apart and two sections asking for regions share one
|
||||
fetch.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useResource } from "../lib/useResource.js";
|
||||
|
||||
const EMPTY = { organizations: [] };
|
||||
|
||||
export function useOrganizations(kind) {
|
||||
const path = kind
|
||||
? `/organizations?kind=${encodeURIComponent(kind)}`
|
||||
: "/organizations";
|
||||
|
||||
const { data, error, loading } = useResource(path, { fallback: EMPTY });
|
||||
|
||||
return {
|
||||
organizations: data?.organizations ?? [],
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Where an organization's page lives ────────────────────────
|
||||
One place to change when routes move. Kinds with no page of
|
||||
their own return null, and a list should render no link rather
|
||||
than a dead one.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
const PATHS = {
|
||||
region: "/regions",
|
||||
chapter: "/chapters",
|
||||
partner: "/partners",
|
||||
};
|
||||
|
||||
export function orgPath(org) {
|
||||
const base = PATHS[org?.kind];
|
||||
return base ? `${base}/${org.id}` : null;
|
||||
}
|
||||
|
||||
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
|
||||
The areas a region covers, annotated where it holds only part
|
||||
of one. Canada is a band on the map rather than somewhere you'd
|
||||
list, so it's left out of the sentence.
|
||||
|
||||
The note comes straight off the row. The old statesLabel had to
|
||||
work out whether this region was the primary or the secondary of
|
||||
a split before it knew which note applied; there's no such thing
|
||||
any more.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function areasSentence(areas = []) {
|
||||
return areas
|
||||
.filter((area) => area.area_code !== "CANADA")
|
||||
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/* Initials for a card with no logo. "NGU Lynnwood" → "NL", dropping
|
||||
the org prefix so every card doesn't read "NG". */
|
||||
export function initialsFor(name = "") {
|
||||
return name
|
||||
.replace(/^NGU\s+/i, "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0].toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
140
src/index.css
140
src/index.css
|
|
@ -69,3 +69,143 @@ h1, h2, h3, h4, h5, h6 {
|
|||
.footer-icons:hover .ig-icon { fill: var(--ig-fill); }
|
||||
.fb-icon:hover { fill: #0862f7; }
|
||||
.ds-icon:hover { fill: #5865f1; }
|
||||
|
||||
|
||||
/* Event Card Section */
|
||||
.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";
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SKELETON
|
||||
Holds roughly a card's height so the page doesn't jump when the
|
||||
data arrives. The shimmer is decoration, so it goes away for
|
||||
anyone who has asked for less motion.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.skeleton {
|
||||
border-radius: 1.5rem;
|
||||
min-height: 22rem;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(0, 0, 0, 0.04) 30%,
|
||||
rgba(0, 0, 0, 0.08) 50%,
|
||||
rgba(0, 0, 0, 0.04) 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
UNITY REGIONS
|
||||
Append to index.css. Scoped to .ur- so nothing here reaches the
|
||||
rest of the site.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* The 0fr → 1fr grid row animates to the panel's real height, so
|
||||
nobody has to guess a max-height that's wrong the moment a
|
||||
region's description grows. The inline style sets the value;
|
||||
this sets how it moves. */
|
||||
.ur-panel {
|
||||
display: grid;
|
||||
transition: grid-template-rows 300ms ease;
|
||||
}
|
||||
|
||||
.ur-chevron {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ur-panel,
|
||||
.ur-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST
|
||||
Append to index.css. Scoped to .ol- so nothing here reaches the
|
||||
rest of the site.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* The 0fr → 1fr grid row animates to the panel's real height, so
|
||||
nobody has to guess a max-height that's wrong the moment a
|
||||
description grows. The inline style sets the value; this sets
|
||||
how it moves. */
|
||||
.ol-panel {
|
||||
display: grid;
|
||||
transition: grid-template-rows 300ms ease;
|
||||
}
|
||||
|
||||
.ol-chevron {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ol-panel,
|
||||
.ol-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/lib/api.js
Normal file
102
src/lib/api.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
API CLIENT
|
||||
|
||||
One place that knows how to talk to the server, so components
|
||||
never call fetch directly and swapping the transport later is a
|
||||
single-file change.
|
||||
|
||||
Two things worth knowing about the design:
|
||||
|
||||
The cache is a module-level Map of in-flight and settled
|
||||
promises. Two components asking for /events during the same
|
||||
render pass share one request, and a remount inside the TTL
|
||||
costs nothing. It resets on page load, which is the right
|
||||
lifetime for content that changes weekly.
|
||||
|
||||
Every reader can pass a `fallback`. If the request fails, that
|
||||
value is used instead. This is what keeps the Figma preview
|
||||
working: pass the old static module as the fallback and the
|
||||
preview renders real content with no server in sight.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const BASE = import.meta.env?.VITE_API_BASE ?? "/api";
|
||||
const DEFAULT_TTL = 60_000;
|
||||
|
||||
const cache = new Map(); // path → { at, promise }
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status, fields } = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
headers: { Accept: "application/json", ...options.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 204) return null;
|
||||
|
||||
const type = response.headers.get("content-type") ?? "";
|
||||
if (!type.includes("application/json")) {
|
||||
// Usually the SPA fallback returning index.html for a URL the
|
||||
// API doesn't serve. Parsing it would throw something useless.
|
||||
throw new ApiError("Server did not return JSON.", {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(body.error ?? "Request failed.", {
|
||||
status: response.status,
|
||||
fields: body.fields,
|
||||
});
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/* ── Reads ─────────────────────────────────────────────────────
|
||||
get("/events") → cached for 60s
|
||||
get("/events", { ttl: 0 }) → always fresh
|
||||
get("/events", { fallback }) → fallback on any failure
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
|
||||
const hit = cache.get(path);
|
||||
|
||||
if (hit && Date.now() - hit.at < ttl) return hit.promise;
|
||||
|
||||
const promise = request(path).catch((err) => {
|
||||
cache.delete(path); // a failure shouldn't be cached
|
||||
if (fallback !== undefined) {
|
||||
console.warn(`api: ${path} failed, using fallback`, err);
|
||||
return fallback;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
cache.set(path, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function invalidate(path) {
|
||||
if (path) cache.delete(path);
|
||||
else cache.clear();
|
||||
}
|
||||
|
||||
/* ── Writes ──────────────────────────────────────────────────── */
|
||||
|
||||
export function post(path, data) {
|
||||
return request(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
78
src/lib/sections.tsx
Normal file
78
src/lib/sections.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from "react";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION MANIFEST
|
||||
|
||||
A page is a list of sections, and a section is a view over data
|
||||
the database already holds, narrowed by a filter. The map of
|
||||
chapters and the vertical list of regions are the same rows seen
|
||||
two ways; the three bands of retreats are one view seen three
|
||||
times with a different filter each.
|
||||
|
||||
So a page declares what it wants and this turns it into what
|
||||
PageShell takes. Every entry looks like:
|
||||
|
||||
{
|
||||
id, title, blurb, accent, background, // the heading
|
||||
Component, // the view
|
||||
props: { ... }, // the filter
|
||||
views: { // optional toggle
|
||||
options: ["carousel", "grid"],
|
||||
default: "carousel",
|
||||
Toggle: EventCardsToggle,
|
||||
},
|
||||
}
|
||||
|
||||
Sections fetch their own data. The page never does, which is why
|
||||
there's no loading or error state here — each view handles its
|
||||
own, and one section failing doesn't blank the page.
|
||||
|
||||
Every Component receives `accent` so its empty and error notices
|
||||
match the heading above them, and `view` when the entry declares
|
||||
a toggle. Both are ignored harmlessly by a section that doesn't
|
||||
want them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export function useSectionManifest(manifest) {
|
||||
// One entry per section that has a toggle, seeded from its
|
||||
// declared default so the control is right before anything loads.
|
||||
const [views, setViews] = useState(() =>
|
||||
Object.fromEntries(
|
||||
manifest
|
||||
.filter(entry => entry.views)
|
||||
.map(entry => [
|
||||
entry.id,
|
||||
entry.views.default ?? entry.views.options?.[0] ?? null,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
|
||||
return manifest.map(entry => {
|
||||
const { Component, props, views: spec, ...heading } = entry;
|
||||
const view = views[entry.id];
|
||||
const Toggle = spec?.Toggle;
|
||||
|
||||
return {
|
||||
...heading,
|
||||
|
||||
actions: Toggle ? (
|
||||
<Toggle
|
||||
view={view}
|
||||
setView={value => setView(entry.id, value)}
|
||||
accent={entry.accent}
|
||||
options={spec.options}
|
||||
/>
|
||||
) : undefined,
|
||||
|
||||
content: (
|
||||
<Component
|
||||
{...(props ?? {})}
|
||||
accent={entry.accent}
|
||||
{...(spec ? { view } : {})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
52
src/lib/useResource.js
Normal file
52
src/lib/useResource.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
useResource
|
||||
|
||||
The read hook every page uses:
|
||||
|
||||
const { data, error, loading } = useResource("/events", {
|
||||
fallback: { events: EVENTS }, // the old static module
|
||||
});
|
||||
|
||||
Deliberately small. If the site ever needs mutation, refetch on
|
||||
focus, or pagination, that's the point to reach for TanStack
|
||||
Query rather than growing this file.
|
||||
|
||||
Note the `ignore` flag rather than an AbortController: the
|
||||
request is shared and cached, so cancelling it would throw away
|
||||
work another component may still want. We just stop writing
|
||||
state after unmount.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "./api.js";
|
||||
|
||||
export function useResource(path, { ttl, fallback } = {}) {
|
||||
// Seed with the fallback so the first paint has content when one
|
||||
// is available, rather than flashing a spinner and then the same
|
||||
// data a moment later.
|
||||
const [state, setState] = useState(() => ({
|
||||
data: fallback,
|
||||
error: null,
|
||||
loading: true,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
get(path, { ttl, fallback })
|
||||
.then((data) => {
|
||||
if (!ignore) setState({ data, error: null, loading: false });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!ignore) setState((prev) => ({ ...prev, error, loading: false }));
|
||||
});
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [path, ttl]); // fallback is intentionally not a dependency
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -1,717 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
GROUPS,
|
||||
CHAPTERS,
|
||||
groupOf,
|
||||
groupSubtext,
|
||||
DOMESTIC,
|
||||
INTERNATIONAL,
|
||||
VIRTUAL,
|
||||
SPLITS,
|
||||
STATE_GRID,
|
||||
MAP_BANDS,
|
||||
STATE_NAMES,
|
||||
GROUP_BY_ID,
|
||||
GROUP_BY_STATE,
|
||||
groupsForState,
|
||||
CHAPTER_COUNT_BY_STATE,
|
||||
chaptersIn,
|
||||
} from "./chapters.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
LOCAL CHAPTERS
|
||||
A tile-grid map on the left, the chapter list on the right.
|
||||
Selecting a region on either side filters both.
|
||||
|
||||
Why a tile grid rather than a geographic map: every state reads
|
||||
at the same size (so Rhode Island is as clickable as Texas), it
|
||||
stays legible on a phone, it needs no map library or GeoJSON,
|
||||
and a state split between two regions is just a tile painted in
|
||||
two colors. If you later want true geography, the swap point is
|
||||
<RegionMap> — everything else works off the data.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const TILE = 100;
|
||||
const PAD = 5;
|
||||
const COLS = 13;
|
||||
const ROWS = 7;
|
||||
|
||||
// How wide the map + list block runs. The section heading above it
|
||||
// stays at max-w-6xl, so this deliberately breaks out past it.
|
||||
const CONTENT_MAX = "88rem";
|
||||
|
||||
function Tile({
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
width = size,
|
||||
label,
|
||||
selected,
|
||||
hovered,
|
||||
setSelected,
|
||||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}) {
|
||||
const split = SPLITS[code];
|
||||
const [primary, secondary] = groupsForState(code);
|
||||
if (!primary) return null;
|
||||
|
||||
const count = CHAPTER_COUNT_BY_STATE[code] || 0;
|
||||
const ids = [primary.id, secondary?.id].filter(Boolean);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
|
||||
// Clicking a split tile cycles primary → secondary → clear, so
|
||||
// both halves are reachable without a second control.
|
||||
const cycle = () => {
|
||||
onPick?.(code);
|
||||
if (!secondary) return setSelected(selected === primary.id ? null : primary.id);
|
||||
if (selected === primary.id) return setSelected(secondary.id);
|
||||
if (selected === secondary.id) return setSelected(null);
|
||||
setSelected(primary.id);
|
||||
};
|
||||
|
||||
const sliverH = split ? size * split.share : 0;
|
||||
const sliverY = split && split.edge === "top" ? y : y + size - sliverH;
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={cycle}
|
||||
onMouseEnter={() => setHovered(primary.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
<title>
|
||||
{STATE_NAMES[code] || code} —{" "}
|
||||
{secondary ? `${primary.name} / ${secondary.name}` : primary.name}
|
||||
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
|
||||
</title>
|
||||
|
||||
<clipPath id={clipId}>
|
||||
<rect x={x} y={y} width={width} height={size} rx={14} />
|
||||
</clipPath>
|
||||
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
fill={primary.color}
|
||||
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
{split && (
|
||||
<rect
|
||||
x={x}
|
||||
y={sliverY}
|
||||
width={width}
|
||||
height={sliverH}
|
||||
fill={secondary.color}
|
||||
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + size / 2 + 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
</text>
|
||||
|
||||
{count > 0 && (
|
||||
<circle
|
||||
cx={x + width - 14}
|
||||
cy={y + 14}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.9}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionMap(props) {
|
||||
const size = TILE - PAD * 2;
|
||||
// `onPick` rides along in props to each Tile via the spread below.
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
|
||||
className="w-full h-auto"
|
||||
role="group"
|
||||
aria-label="Chapters by region"
|
||||
>
|
||||
{/* Wide areas (Canada) */}
|
||||
{Object.entries(MAP_BANDS).map(([code, band]) => {
|
||||
const [c1, c2] = band.cols;
|
||||
return (
|
||||
<Tile
|
||||
key={code}
|
||||
code={code}
|
||||
label={band.label}
|
||||
x={(c1 - 1) * TILE + PAD}
|
||||
y={(band.row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
width={(c2 - c1 + 1) * TILE - PAD * 2}
|
||||
fontSize={38}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* States */}
|
||||
{Object.entries(STATE_GRID).map(([code, [col, row]]) => (
|
||||
<Tile
|
||||
key={code}
|
||||
code={code}
|
||||
x={(col - 1) * TILE + PAD}
|
||||
y={(row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendButton({ group, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === group.id || hovered === group.id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === group.id ? null : group.id)}
|
||||
onMouseEnter={() => setHovered(group.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(group.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-pressed={selected === group.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${group.color}`,
|
||||
background: on ? group.color : "transparent",
|
||||
color: on ? "#ffffff" : group.color,
|
||||
opacity: selected && selected !== group.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : group.color }}
|
||||
/>
|
||||
{group.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend(props) {
|
||||
const { selected, setSelected } = props;
|
||||
const onMap = g => g.states.length > 0 || g.id === "west-central";
|
||||
const us = DOMESTIC.filter(onMap);
|
||||
const intl = INTERNATIONAL.filter(onMap);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
|
||||
US Regions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{us.map(g => (
|
||||
<LegendButton key={g.id} group={g} {...props} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px my-4 bg-[#cfe3e7]" />
|
||||
|
||||
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
|
||||
International
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{intl.map(g => (
|
||||
<LegendButton key={g.id} group={g} {...props} />
|
||||
))}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="py-1.5 px-3 rounded-lg text-sm font-700 text-[#4a6b72] underline"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupBlock({
|
||||
group,
|
||||
selected,
|
||||
setSelected,
|
||||
setHovered,
|
||||
indent,
|
||||
groupRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
const chapters = chaptersIn(group.id);
|
||||
const on = selected === group.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => groupRefs && (groupRefs.current[group.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(on ? null : group.id)}
|
||||
onMouseEnter={() => setHovered(group.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full flex items-baseline gap-2 text-left py-2"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: group.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
{group.name}
|
||||
</h4>
|
||||
<span className="text-sm text-[#7a9299] ml-auto">
|
||||
{chapters.length || "—"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{groupSubtext(group) && (
|
||||
<p className="text-sm text-[#7a9299] mb-2 ml-5 leading-snug">
|
||||
{groupSubtext(group)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm text-[#7a9299] ml-5 mb-4">
|
||||
No chapters yet — interested in starting one?
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3"
|
||||
style={{ borderLeft: `2px solid ${group.color}` }}
|
||||
>
|
||||
<p className="font-700">{c.name}</p>
|
||||
<p className="text-sm text-[#4a6b72]">
|
||||
{c.city}
|
||||
{c.meets ? ` · ${c.meets}` : ""}
|
||||
</p>
|
||||
{(c.link || c.contact) && (
|
||||
<p className="text-sm mt-1 flex gap-4">
|
||||
{c.link && (
|
||||
<a
|
||||
href={c.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
{c.contact && (
|
||||
<a
|
||||
href={`mailto:${c.contact}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
GRID VIEW
|
||||
One grid per region. Clicking a card's arrow opens a detail
|
||||
panel directly above that region's grid, framed in the region
|
||||
color. Selecting another card swaps the panel's contents.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/chapter-logos/";
|
||||
|
||||
/* Logo, or the chapter's initials when there's no file. */
|
||||
function ChapterLogo({ chapter, color, size = "h-14 w-14" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const initials = chapter.name
|
||||
.replace(/^NGU\s+/i, "")
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
if (chapter.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${chapter.logo}`}
|
||||
alt={chapter.name}
|
||||
onError={() => setFailed(true)}
|
||||
className={`${size} object-contain rounded-xl shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-label={chapter.name}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: open ? `${color}14` : "transparent",
|
||||
}}
|
||||
>
|
||||
<ChapterLogo chapter={chapter} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700 leading-tight">{chapter.name}</p>
|
||||
<p className="text-sm text-[#4a6b72]">{chapter.city}</p>
|
||||
{chapter.meets && (
|
||||
<p className="text-sm text-[#7a9299]">{chapter.meets}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
|
||||
className="shrink-0 h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({ chapter, group, onClose }) {
|
||||
const rows = [
|
||||
["Region", group.name],
|
||||
["Where", chapter.where],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", chapter.leads],
|
||||
["Since", chapter.started],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${group.color}`, background: `${group.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<ChapterLogo chapter={chapter} color={group.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
<p className="text-[#4a6b72]">{chapter.city}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chapter.about && (
|
||||
<p className="mt-4 leading-relaxed text-[#2c4a50]">{chapter.about}</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2">
|
||||
<dt className="text-[#7a9299] shrink-0">{label}</dt>
|
||||
<dd className="font-700 text-[#2c4a50]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{(chapter.link || chapter.contact) && (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{chapter.link && (
|
||||
<a
|
||||
href={chapter.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
Visit page
|
||||
</a>
|
||||
)}
|
||||
{chapter.contact && (
|
||||
<a
|
||||
href={`mailto:${chapter.contact}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterGrid({ openId, setOpenId }) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = GROUPS.map(g => ({ group: g, list: chaptersIn(g.id) })).filter(
|
||||
({ list }) => list.length > 0
|
||||
);
|
||||
const empty = GROUPS.filter(g => chaptersIn(g.id).length === 0);
|
||||
const openChapter = CHAPTERS.find(c => c.id === openId) || null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ group, list }) => (
|
||||
<section key={group.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: group.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: group.color }}>
|
||||
{group.name}
|
||||
</h3>
|
||||
<span className="text-sm text-[#7a9299]">{list.length}</span>
|
||||
</div>
|
||||
{groupSubtext(group) && (
|
||||
<p className="text-sm text-[#7a9299] mb-5 ml-6">{groupSubtext(group)}</p>
|
||||
)}
|
||||
|
||||
{/* Detail panel sits above this region's grid, and only
|
||||
when the open chapter belongs to this region. */}
|
||||
{openChapter && groupOf(openChapter)?.id === group.id && (
|
||||
<ChapterDetail
|
||||
chapter={openChapter}
|
||||
group={group}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-4 items-start"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
|
||||
}}
|
||||
>
|
||||
{list.map(c => (
|
||||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={group.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{empty.length > 0 && (
|
||||
<p className="text-sm text-[#7a9299]">
|
||||
No chapters yet in {empty.map(g => g.name).join(", ")} — interested in
|
||||
starting one?
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function ChaptersViewToggle({ 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 chapters are displayed"
|
||||
>
|
||||
{[
|
||||
["map", "Map"],
|
||||
["grid", "Grid"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
aria-pressed={view === id}
|
||||
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocalChapters({ view = "map" }) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||
|
||||
// The list scrolls itself to whatever the map or legend points at.
|
||||
const listRef = useRef(null);
|
||||
const groupRefs = useRef({});
|
||||
const chapterRefs = useRef({});
|
||||
|
||||
const scrollListTo = el => {
|
||||
const box = listRef.current;
|
||||
if (!box || !el) return;
|
||||
// Only when the list is its own scroll area (lg and up). Below
|
||||
// that it's stacked under the map and scrolling it would fight
|
||||
// the page.
|
||||
if (box.scrollHeight <= box.clientHeight) return;
|
||||
box.scrollTo({ top: el.offsetTop - 8, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Hovering or selecting a region brings that block into view.
|
||||
useEffect(() => {
|
||||
const id = hovered || selected;
|
||||
if (id) scrollListTo(groupRefs.current[id]);
|
||||
}, [hovered, selected]);
|
||||
|
||||
// Clicking a state jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickState = code => {
|
||||
const chapter = CHAPTERS.find(c => c.state === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
}
|
||||
const group = GROUP_BY_STATE[code];
|
||||
if (group) scrollListTo(groupRefs.current[group.id]);
|
||||
};
|
||||
|
||||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const listProps = { ...shared, groupRefs, chapterRefs };
|
||||
|
||||
if (view === "grid") {
|
||||
return <ChapterGrid openId={openId} setOpenId={setOpenId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
/* Wider than the section heading above it — the map needs the
|
||||
room. CONTENT_MAX is the knob; drop it toward 72rem to pull
|
||||
the whole block back in line with the heading. */
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<RegionMap {...shared} onPick={pickState} />
|
||||
<Legend {...shared} />
|
||||
<p className="text-sm text-[#7a9299] mt-4">
|
||||
A filled tile means a chapter meets there; a two-tone tile is a
|
||||
state shared by two regions. Select a region to filter the list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* List — h-0 + min-h-full makes this column take its height
|
||||
from the map column rather than the other way round, so a
|
||||
long list scrolls instead of stretching the section. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<h3 className="text-xl font-800 mb-1 text-[#4a6b72]">US Regions</h3>
|
||||
{DOMESTIC.map(g => (
|
||||
<GroupBlock key={g.id} group={g} indent {...listProps} />
|
||||
))}
|
||||
|
||||
<h3 className="text-xl font-800 mt-6 mb-1 pt-4 border-t border-[#cfe3e7] text-[#4a6b72]">
|
||||
International
|
||||
</h3>
|
||||
{INTERNATIONAL.map(g => (
|
||||
<GroupBlock key={g.id} group={g} indent {...listProps} />
|
||||
))}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-[#cfe3e7]">
|
||||
{VIRTUAL.map(g => (
|
||||
<GroupBlock key={g.id} group={g} {...listProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,63 +1,69 @@
|
|||
import { useState } from "react";
|
||||
import PageShell from "../components/PageShell.jsx";
|
||||
import LocalChapters, { ChaptersViewToggle } from "./Chapters.jsx";
|
||||
import PageShell from "../components/PageShell.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
|
||||
import OrgListVertical from "./sections/OrgList-Vertical.tsx";
|
||||
import OrgListCards from "./sections/OrgList-Card.tsx";
|
||||
|
||||
/* Section ids match the nav hashes: #local, #virtual, #partners.
|
||||
`content` is whatever you want inside — cards, a list, plain
|
||||
copy. The empty divs below are placeholders to build into. */
|
||||
const LOCAL_ACCENT = "#138ba0";
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
COMMUNITY PAGE
|
||||
|
||||
Two views of the organizations table. The map is filtered to
|
||||
chapters; the vertical list is filtered to regions. Same rows,
|
||||
same endpoint, different shape on the page.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "chapters",
|
||||
title: "Local Chapters",
|
||||
blurb:
|
||||
"Young adult groups meeting in person and online across the movement.",
|
||||
accent: "#138ba0",
|
||||
background: "#eef9fb",
|
||||
Component: OrgListMap,
|
||||
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
|
||||
},
|
||||
{
|
||||
id: "regions",
|
||||
title: "Unity Regions",
|
||||
blurb:
|
||||
"The regional organizations that support chapters and host their own gatherings.",
|
||||
accent: "#4a6b72",
|
||||
background: "#ffffff",
|
||||
Component: OrgListVertical,
|
||||
props: {
|
||||
kind: "region",
|
||||
pageLabel: "Region page",
|
||||
groupBy: org => org.details?.scope,
|
||||
groups: [
|
||||
{ key: "domestic", title: "US Unity Regions" },
|
||||
{ key: "international", title: "International Unity Regions" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "partners",
|
||||
title: "Partner Organizations",
|
||||
blurb:
|
||||
"Organizations we collaborate with across the Unity movement and beyond.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
Component: OrgListCards,
|
||||
props: {
|
||||
kind: "partner",
|
||||
pageLabel: "Partner page",
|
||||
empty: "· Partner organizations coming soon ·",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityPage() {
|
||||
// Map or grid for the chapters section. Lives here so the toggle in
|
||||
// the heading's action bar and the content below it stay in sync.
|
||||
const [chaptersView, setChaptersView] = useState("map");
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: "local",
|
||||
title: "Local Chapters",
|
||||
blurb: "Groups meeting in person around the country. Find one near you.",
|
||||
accent: LOCAL_ACCENT,
|
||||
background: "#eef9fb",
|
||||
actions: (
|
||||
<ChaptersViewToggle
|
||||
view={chaptersView}
|
||||
setView={setChaptersView}
|
||||
accent={LOCAL_ACCENT}
|
||||
/>
|
||||
),
|
||||
content: <LocalChapters view={chaptersView} />,
|
||||
},
|
||||
{
|
||||
id: "virtual",
|
||||
title: "Virtual Community",
|
||||
blurb: "Connect from anywhere, no chapter nearby required.",
|
||||
accent: "#aac992",
|
||||
background: "#ffffff",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Virtual community content */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "partners",
|
||||
title: "Partner Organizations",
|
||||
blurb: "Organizations we collaborate with across the Unity movement.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Partner organizations content */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
const sections = useSectionManifest(SECTIONS);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Community"
|
||||
intro="Ways to connect with Next Generation of Unity, in your area, online, and through the organizations we work alongside."
|
||||
intro="Chapters, regions and the people who make up Next Generation of Unity."
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,302 +1,39 @@
|
|||
import { useState } from "react";
|
||||
import PageShell from "../components/PageShell.jsx";
|
||||
import PageShell from "../components/PageShell";
|
||||
import FeedbackForm from "./sections/FeedbackForm";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Feedback page.
|
||||
|
||||
Section ids match the nav hashes. Right now there's one:
|
||||
#website. Add more entries to SECTIONS (bottom of file) as the
|
||||
page grows — program feedback, retreat surveys, etc.
|
||||
#website. Add more entries to SECTIONS as the page grows —
|
||||
program feedback, retreat surveys, etc.
|
||||
|
||||
Nothing is persisted. handleSubmit just flips to the thank-you
|
||||
panel; the POST goes where the comment marks it.
|
||||
The form itself lives in sections/FeedbackForm, including its
|
||||
own type list, location picker, and the POST to /api/feedback.
|
||||
This file only decides where it sits on the page.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const MUTED = "#4a6b72";
|
||||
const MAX_CHARS = 1500;
|
||||
|
||||
// Selectable feedback types. Add or reword freely — the grid reflows.
|
||||
const FEEDBACK_TYPES = [
|
||||
{
|
||||
id: "broken",
|
||||
label: "Something's broken",
|
||||
hint: "A link, image, or button that doesn't work",
|
||||
},
|
||||
{
|
||||
id: "confusing",
|
||||
label: "Hard to use",
|
||||
hint: "Something you couldn't find or follow",
|
||||
},
|
||||
{
|
||||
id: "outdated",
|
||||
label: "Wrong or missing info",
|
||||
hint: "Old dates, typos, an event that isn't listed",
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
label: "Feature request",
|
||||
hint: "Something you'd like the site to do",
|
||||
},
|
||||
{
|
||||
id: "praise",
|
||||
label: "Kind words",
|
||||
hint: "Tell us what's working well",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
hint: "Anything that doesn't fit the boxes above",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Shared field chrome ─────────────────────────────────────── */
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||
"placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
|
||||
"focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
function OptionalTag() {
|
||||
return (
|
||||
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
Optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
What kind of feedback is this?
|
||||
</legend>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
Pick the closest fit. It helps us route it to the right person.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEEDBACK_TYPES.map((type) => {
|
||||
const selected = value === type.id;
|
||||
return (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feedbackType"
|
||||
value={type.id}
|
||||
checked={selected}
|
||||
onChange={() => onChange(type.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#eef9fb]"
|
||||
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-[#26454c]">
|
||||
{type.label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
|
||||
: "border-[#4a6b72]/30")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{type.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The form ────────────────────────────────────────────────── */
|
||||
|
||||
function WebsiteFeedbackForm() {
|
||||
const [type, setType] = useState(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const ready = Boolean(type) && message.trim().length > 0;
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!ready) return;
|
||||
// TODO: POST { type, message, name, email } somewhere.
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setType(null);
|
||||
setMessage("");
|
||||
setName("");
|
||||
setEmail("");
|
||||
setSent(false);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
|
||||
Thanks — we've got it
|
||||
</h3>
|
||||
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
|
||||
{email
|
||||
? `We'll follow up at ${email} if we have questions.`
|
||||
: "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<TypePicker value={type} onChange={setType} />
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-10">
|
||||
<label
|
||||
htmlFor="feedback-message"
|
||||
className="text-base font-semibold text-[#26454c]"
|
||||
>
|
||||
Tell us more
|
||||
</label>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
The page you were on and what you expected to happen are the two most
|
||||
useful things you can give us.
|
||||
</p>
|
||||
<textarea
|
||||
id="feedback-message"
|
||||
rows={7}
|
||||
maxLength={MAX_CHARS}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I was looking for the summer retreat dates on the Events page and…"
|
||||
className={`mt-4 resize-y ${fieldClass}`}
|
||||
/>
|
||||
<div className="mt-2 text-right text-xs tabular-nums" style={{ color: MUTED }}>
|
||||
{message.length} / {MAX_CHARS}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional contact details */}
|
||||
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">
|
||||
Your details
|
||||
</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave these blank and your feedback comes through anonymously. Fill
|
||||
them in only if you'd like a reply — we won't add you to any list.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-name"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready}
|
||||
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
|
||||
>
|
||||
Send feedback
|
||||
</button>
|
||||
{!ready && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
Choose a type and write a note to send.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────── */
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "website",
|
||||
title: "Website Feedback and Requests",
|
||||
title: "Website Feedback",
|
||||
blurb:
|
||||
"Found a broken link, spotted something out of date, or want the site to do something it doesn't? This form goes straight to the people who maintain it.",
|
||||
accent: ACCENT,
|
||||
background: "#eef9fb",
|
||||
content: (
|
||||
// Narrower than the usual max-w-6xl: full-width form fields
|
||||
// are unpleasant to fill in.
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<WebsiteFeedbackForm />
|
||||
<FeedbackForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function FeedbackPage() {
|
||||
export default function Feedback() {
|
||||
return (
|
||||
<PageShell
|
||||
title="Feedback"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,62 @@
|
|||
import PageShell from "../components/PageShell.jsx";
|
||||
import PageShell from "../components/PageShell.tsx";
|
||||
import PeopleTiles from "../components/PeopleTiles.tsx";
|
||||
|
||||
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
|
||||
// the component only cares about the shape, not where it came from.
|
||||
|
||||
export const leadershipGroups = [
|
||||
{
|
||||
id: "director",
|
||||
label: "Retreat Director",
|
||||
accent: "#138ba0",
|
||||
people: [
|
||||
{
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
title: "Retreat Director",
|
||||
photo: "/people/jordan-ellis.jpg",
|
||||
pronouns: "he/him",
|
||||
birthdate: "1994-03-08",
|
||||
org: { name: "Unity of Des Moines", href: "https://example.org" },
|
||||
bio: [
|
||||
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
|
||||
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
label: "Retreat Team",
|
||||
people: [
|
||||
{
|
||||
id: 2,
|
||||
name: "Priya Raman",
|
||||
title: "Events Lead",
|
||||
photo: "/people/priya-raman.jpg",
|
||||
pronouns: "she/her",
|
||||
age: 27,
|
||||
org: "Unity Chicago",
|
||||
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Sam Okafor",
|
||||
title: "Communications",
|
||||
pronouns: "they/them",
|
||||
age: 24,
|
||||
org: "Unity of Milwaukee",
|
||||
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const board = [
|
||||
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
|
||||
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
|
||||
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
|
|
@ -9,7 +67,7 @@ const SECTIONS = [
|
|||
background: "#eef9fb",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Board & staff content */}
|
||||
<PeopleTiles size="sm" people={board} overflow="scroll" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -21,7 +79,7 @@ const SECTIONS = [
|
|||
background: "#ffffff",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Retreat team content */}
|
||||
<PeopleTiles size="lg" groups={leadershipGroups} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,590 +1,61 @@
|
|||
import { useState } from "react";
|
||||
import PageShell from "../components/PageShell.tsx"
|
||||
import eventsData from "../events.js";
|
||||
import PageShell from "../components/PageShell.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RETREATS PAGE
|
||||
|
||||
const LOGOS = Object.fromEntries(
|
||||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
Three bands, all the same view of the same table with a
|
||||
different filter. The page declares them and does nothing else —
|
||||
each section fetches its own slice.
|
||||
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
To reorder the page, move a line. To add a different kind of
|
||||
section, add an entry with another Component.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// Used when an event doesn't name its own org_logo.
|
||||
const DEFAULT_ORG_LOGO = null;
|
||||
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "national",
|
||||
title: "National Retreats",
|
||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
||||
accent: "#138ba0",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "national" },
|
||||
views: { ...CARD_VIEWS, default: "carousel" },
|
||||
},
|
||||
{
|
||||
id: "regional",
|
||||
title: "Regional Retreats",
|
||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
||||
accent: "#aac992",
|
||||
background: "#ffffff",
|
||||
Component: EventListCards,
|
||||
props: { section: "regional" },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
{
|
||||
id: "partner",
|
||||
title: "Partner Events",
|
||||
blurb: "Events hosted by organizations we collaborate with.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "partner" },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
];
|
||||
|
||||
export default function RetreatsPage() {
|
||||
const sections = useSectionManifest(SECTIONS);
|
||||
|
||||
/* 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)}
|
||||
<PageShell
|
||||
title="Retreats"
|
||||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
LOCAL CHAPTER DATA
|
||||
|
||||
GROUPS every group that can appear in the list: the 7
|
||||
domestic regions, the international ones, virtual
|
||||
STATE_GRID where each state sits on the tile map
|
||||
SPLITS states shared by two regions (see below)
|
||||
CHAPTERS the chapters themselves
|
||||
|
||||
To add a chapter: add an object to CHAPTERS. Its group is looked
|
||||
up from its state, so the map and the list can't drift apart —
|
||||
except in a split state, where the chapter names its region.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Groups ───────────────────────────────────────────────────
|
||||
scope "domestic" → its own block in the list
|
||||
"international" → nested under the International heading
|
||||
"virtual" → region-agnostic, listed last
|
||||
states which tiles it owns on the map ([] = not on the map)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const GROUPS = [
|
||||
{
|
||||
id: "eastern",
|
||||
name: "Eastern",
|
||||
color: "#7a5ea8",
|
||||
scope: "domestic",
|
||||
states: [
|
||||
"WV", "VA", "MD", "DC", "NY", "PA", "ME",
|
||||
"NJ", "DE", "CT", "VT", "MA", "NH", "RI",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "great-lakes",
|
||||
name: "Great Lakes",
|
||||
color: "#4a6fa5",
|
||||
scope: "domestic",
|
||||
states: ["MN", "IA", "WI", "IL", "IN", "MI", "OH", "KY"],
|
||||
},
|
||||
{
|
||||
id: "northwest",
|
||||
name: "Northwest",
|
||||
color: "#138ba0",
|
||||
scope: "domestic",
|
||||
states: ["AK", "WA", "OR", "ID", "MT"],
|
||||
},
|
||||
{
|
||||
id: "south-central",
|
||||
name: "South Central",
|
||||
color: "#d4703f",
|
||||
scope: "domestic",
|
||||
states: [
|
||||
"ND", "SD", "WY", "NE", "CO", "NM",
|
||||
"TX", "OK", "KS", "MO", "AR", "LA",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "southeast",
|
||||
name: "Southeast",
|
||||
color: "#c25e8b",
|
||||
scope: "domestic",
|
||||
states: ["TN", "MS", "AL", "GA", "NC", "SC", "FL"],
|
||||
},
|
||||
{
|
||||
id: "southwest",
|
||||
name: "Southwest",
|
||||
color: "#e0a32e",
|
||||
scope: "domestic",
|
||||
states: ["AZ"],
|
||||
},
|
||||
{
|
||||
id: "west-central",
|
||||
name: "West Central",
|
||||
color: "#7a9e5a",
|
||||
scope: "domestic",
|
||||
states: [],
|
||||
},
|
||||
|
||||
|
||||
/* ── International ── */
|
||||
{
|
||||
id: "africa",
|
||||
name: "Africa",
|
||||
color: "#c98a3c",
|
||||
scope: "international",
|
||||
states: [],
|
||||
note: "Not yet shown on the map.",
|
||||
},
|
||||
{
|
||||
id: "canada",
|
||||
name: "Canada",
|
||||
color: "#a8577a",
|
||||
scope: "international",
|
||||
states: ["CANADA"], // a map area, not a US state
|
||||
},
|
||||
{
|
||||
id: "pacific-rim",
|
||||
name: "Pacific Rim",
|
||||
color: "#2f9e8f",
|
||||
scope: "international",
|
||||
states: ["HI"],
|
||||
note: "Hawai'i and the wider Pacific.",
|
||||
},
|
||||
{
|
||||
id: "uk",
|
||||
name: "United Kingdom",
|
||||
color: "#6b7fb5",
|
||||
scope: "international",
|
||||
states: [],
|
||||
note: "Not yet shown on the map.",
|
||||
},
|
||||
|
||||
/* ── Region-agnostic ── */
|
||||
{
|
||||
id: "virtual",
|
||||
name: "Virtual",
|
||||
color: "#8aa1a6",
|
||||
scope: "virtual",
|
||||
states: [],
|
||||
note: "Join from anywhere.",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Split states ─────────────────────────────────────────────
|
||||
Some states belong to two regions. The tile is drawn in the
|
||||
primary region's color, then `share` of it — measured from
|
||||
`edge` — is painted in the secondary region's color.
|
||||
|
||||
primary which group owns the rest of the state
|
||||
secondary which group owns the sliver
|
||||
edge "top" | "bottom" — where the sliver sits
|
||||
share 0-1, how much of the tile the sliver takes
|
||||
primaryNote how that share reads in the region subtext
|
||||
secondaryNote "
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const SPLITS = {
|
||||
CA: {
|
||||
primary: "southwest", secondary: "west-central",
|
||||
edge: "top", share: 0.5,
|
||||
primaryNote: "south", secondaryNote: "north",
|
||||
},
|
||||
NV: {
|
||||
primary: "southwest", secondary: "west-central",
|
||||
edge: "top", share: 0.5,
|
||||
primaryNote: "south", secondaryNote: "north",
|
||||
},
|
||||
UT: {
|
||||
primary: "southwest", secondary: "northwest",
|
||||
edge: "top", share: 0.28,
|
||||
primaryNote: "excl. Salt Lake City", secondaryNote: "Salt Lake City area",
|
||||
},
|
||||
IA: {
|
||||
primary: "great-lakes", secondary: "south-central",
|
||||
edge: "bottom", share: 0.22,
|
||||
primaryNote: "most", secondaryNote: "small part",
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Tile map layout ──────────────────────────────────────────
|
||||
Each state is one square on a 12 × 7 grid, placed roughly where
|
||||
it sits on a real map. A tile grid instead of true geography:
|
||||
every state reads at the same size (Rhode Island included), it
|
||||
stays legible on a phone, and there's no map library to load.
|
||||
[column, row], both 1-based.
|
||||
|
||||
CANADA is a wide band across the top — not a state, so it's
|
||||
listed separately in MAP_BANDS below.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const STATE_GRID = {
|
||||
AK: [1, 1], ME: [13, 1],
|
||||
WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
|
||||
MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
|
||||
OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
|
||||
IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
|
||||
CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
|
||||
WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
|
||||
AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
|
||||
DC: [9, 5], RI: [12, 3],
|
||||
OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
|
||||
HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
|
||||
};
|
||||
|
||||
/* Wide areas that span several columns. */
|
||||
export const MAP_BANDS = {
|
||||
CANADA: { cols: [3, 10], row: 1, label: "Canada" },
|
||||
};
|
||||
|
||||
export const STATE_NAMES = {
|
||||
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
|
||||
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
|
||||
DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
|
||||
ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
|
||||
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
|
||||
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
|
||||
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
||||
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
||||
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
|
||||
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
|
||||
SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
|
||||
UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
|
||||
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
|
||||
CANADA: "Canada",
|
||||
};
|
||||
|
||||
/* ── Chapters ─────────────────────────────────────────────────
|
||||
name what the group calls itself
|
||||
city city, or the country for international groups
|
||||
state two-letter code, "CANADA", or null
|
||||
group set this instead of `state` for a group with no map
|
||||
area (africa, uk, virtual)
|
||||
region ONLY needed in a split state (CA, NV, UT, IA) — say
|
||||
which of the two regions the chapter belongs to
|
||||
meets when it gathers
|
||||
link optional URL
|
||||
contact optional email
|
||||
|
||||
Used by the grid view and its detail panel:
|
||||
logo filename in public/chapter-logos/ (e.g. "lynnwood.svg").
|
||||
null → the card shows the chapter's initials instead.
|
||||
about a paragraph for the expanded panel
|
||||
leads who runs it
|
||||
where meeting address or platform
|
||||
started optional "Since 2021"
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const CHAPTERS = [
|
||||
{
|
||||
id: "lynnwood",
|
||||
name: "NGU Lynnwood",
|
||||
city: "Lynnwood, WA",
|
||||
state: "WA",
|
||||
meets: "2nd Sundays, 6:00pm",
|
||||
link: null,
|
||||
contact: null,
|
||||
logo: null,
|
||||
about:
|
||||
"A monthly gathering for young adults in south Snohomish County — potlucks, discussion nights, and service projects.",
|
||||
leads: "Chapter lead name",
|
||||
where: "Unity in Lynnwood",
|
||||
started: null,
|
||||
},
|
||||
{
|
||||
id: "bellingham",
|
||||
name: "NGU Bellingham",
|
||||
city: "Bellingham, WA",
|
||||
state: "WA",
|
||||
meets: "Monthly — dates vary",
|
||||
link: null,
|
||||
contact: null,
|
||||
logo: null,
|
||||
about: "Short description of what this chapter does and who it's for.",
|
||||
leads: null,
|
||||
where: null,
|
||||
started: null,
|
||||
},
|
||||
{
|
||||
id: "unity-village",
|
||||
name: "NGU Unity Village",
|
||||
city: "Unity Village, MO",
|
||||
state: "MO",
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "bay-area",
|
||||
name: "NGU Bay Area",
|
||||
city: "Oakland, CA",
|
||||
state: "CA",
|
||||
region: "west-central", // split state — northern California
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "toronto",
|
||||
name: "NGU Toronto",
|
||||
city: "Toronto, Ontario",
|
||||
state: "CANADA",
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "NGU Discord",
|
||||
city: "Online",
|
||||
state: null,
|
||||
group: "virtual",
|
||||
meets: "Always open",
|
||||
link: "https://discord.com/invite/AtngzpqaX5",
|
||||
contact: null,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Lookups built from the tables above ───────────────────── */
|
||||
|
||||
export const GROUP_BY_ID = Object.fromEntries(GROUPS.map(g => [g.id, g]));
|
||||
|
||||
// "WA" → the Northwest group. Split states resolve to their primary.
|
||||
export const GROUP_BY_STATE = {
|
||||
...Object.fromEntries(GROUPS.flatMap(g => g.states.map(s => [s, g]))),
|
||||
...Object.fromEntries(
|
||||
Object.entries(SPLITS).map(([s, sp]) => [s, GROUP_BY_ID[sp.primary]])
|
||||
),
|
||||
};
|
||||
|
||||
// Both groups that touch a tile, primary first
|
||||
export const groupsForState = code => {
|
||||
const split = SPLITS[code];
|
||||
if (split) return [GROUP_BY_ID[split.primary], GROUP_BY_ID[split.secondary]];
|
||||
const g = GROUP_BY_STATE[code];
|
||||
return g ? [g] : [];
|
||||
};
|
||||
|
||||
// Which group a chapter belongs to
|
||||
export const groupOf = c =>
|
||||
GROUP_BY_ID[c.region] || GROUP_BY_ID[c.group] || GROUP_BY_STATE[c.state];
|
||||
|
||||
export const chaptersIn = groupId =>
|
||||
CHAPTERS.filter(c => groupOf(c)?.id === groupId);
|
||||
|
||||
// { WA: 2, MO: 1, ... } — how many chapters each map area has
|
||||
export const CHAPTER_COUNT_BY_STATE = CHAPTERS.reduce((acc, c) => {
|
||||
if (c.state) acc[c.state] = (acc[c.state] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/* "AK, ID, MT, OR, UT (Salt Lake City area), WA" — the states a
|
||||
region covers, split states annotated, alphabetical. Groups with
|
||||
no states (Africa, UK, Virtual) fall back to their own `note`. */
|
||||
export const statesLabel = group => {
|
||||
// split states are listed below with their annotation, not here
|
||||
const whole = group.states.filter(s => s !== "CANADA" && !SPLITS[s]);
|
||||
const shared = Object.entries(SPLITS)
|
||||
.filter(([, sp]) => sp.primary === group.id || sp.secondary === group.id)
|
||||
.map(([code, sp]) =>
|
||||
sp.primary === group.id
|
||||
? `${code} (${sp.primaryNote})`
|
||||
: `${code} (${sp.secondaryNote})`
|
||||
);
|
||||
return [...whole, ...shared].sort().join(", ");
|
||||
};
|
||||
|
||||
export const groupSubtext = group => {
|
||||
const states = statesLabel(group);
|
||||
if (states && group.note) return `${states} · ${group.note}`;
|
||||
return states || group.note || "";
|
||||
};
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export const DOMESTIC = GROUPS.filter(g => g.scope === "domestic").sort(byName);
|
||||
export const INTERNATIONAL = GROUPS.filter(g => g.scope === "international").sort(byName);
|
||||
export const VIRTUAL = GROUPS.filter(g => g.scope === "virtual");
|
||||
568
src/pages/sections/EventList-Cards.tsx
Normal file
568
src/pages/sections/EventList-Cards.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
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;
|
||||
|
||||
/* 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 }) {
|
||||
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>
|
||||
);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
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.event_logo;
|
||||
const links = ev.links ?? [];
|
||||
const igHandle = ev.instagram || null;
|
||||
const igUrl = igHandle
|
||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: 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">
|
||||
<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_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" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</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 it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className="mt-auto text-center font-600 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({ 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
|
||||
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".
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export default function EventListCards({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
|
||||
/* 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);
|
||||
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 => ({
|
||||
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 => (
|
||||
<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">
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
) : 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
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
494
src/pages/sections/FeedbackForm.tsx
Normal file
494
src/pages/sections/FeedbackForm.tsx
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
WEBSITE FEEDBACK FORM
|
||||
|
||||
Posts to /api/feedback, which is the only public write on the
|
||||
site. The server is the authority on validation; the checks here
|
||||
exist to stop someone hitting a 422 they could have avoided, and
|
||||
the two sets are kept deliberately in step (MIN_MESSAGE, and the
|
||||
ids in FEEDBACK_TYPES).
|
||||
|
||||
The page and section options come from navConfig, so adding a
|
||||
page to the nav adds it to this form too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const MUTED = "#4a6b72";
|
||||
const MAX_CHARS = 1500;
|
||||
const MIN_MESSAGE = 10; // matches the server
|
||||
|
||||
// Sentinel for "this isn't about one particular page".
|
||||
const SITE_WIDE = "site";
|
||||
// Sentinel for "this page, but not one section of it".
|
||||
const WHOLE_PAGE = "";
|
||||
|
||||
// Ids must match TYPES in server/src/routes/feedback.js.
|
||||
const FEEDBACK_TYPES = [
|
||||
{
|
||||
id: "broken",
|
||||
label: "Something's broken",
|
||||
hint: "A link, image, or button that doesn't work",
|
||||
},
|
||||
{
|
||||
id: "confusing",
|
||||
label: "Hard to use",
|
||||
hint: "Something you couldn't find or follow",
|
||||
},
|
||||
{
|
||||
id: "outdated",
|
||||
label: "Wrong or missing info",
|
||||
hint: "Old dates, typos, an event that isn't listed",
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
label: "Feature request",
|
||||
hint: "Something you'd like the site to do",
|
||||
},
|
||||
{
|
||||
id: "praise",
|
||||
label: "Kind words",
|
||||
hint: "Tell us what's working well",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
hint: "Anything that doesn't fit the boxes above",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Shared field chrome ─────────────────────────────────────── */
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||
"placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
|
||||
"focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
const errorFieldClass = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||
|
||||
const selectClass = `${fieldClass} appearance-none pr-10`;
|
||||
|
||||
function OptionalTag() {
|
||||
return (
|
||||
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
Optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Native select plus a chevron, since appearance-none strips the default one.
|
||||
function Select({ id, label, value, onChange, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-2">
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
<path
|
||||
d="M5 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
What kind of feedback is this?
|
||||
</legend>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
Pick the closest fit. It helps us route it to the right person.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEEDBACK_TYPES.map((type) => {
|
||||
const selected = value === type.id;
|
||||
return (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feedbackType"
|
||||
value={type.id}
|
||||
checked={selected}
|
||||
onChange={() => onChange(type.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#eef9fb]"
|
||||
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-[#26454c]">{type.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
|
||||
: "border-[#4a6b72]/30")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{type.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Where on the site ───────────────────────────────────────── */
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">
|
||||
Where did you run into it?
|
||||
</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave this on "Not page-specific" if it applies to the whole site.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
id="feedback-page"
|
||||
label="Page"
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
onPageChange(next);
|
||||
onSectionChange(WHOLE_PAGE); // sections belong to a page
|
||||
}}
|
||||
>
|
||||
<option value={SITE_WIDE}>Not page-specific</option>
|
||||
{PAGE_LINKS.map((p) => (
|
||||
<option key={p.path} value={p.path}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<Select
|
||||
id="feedback-section"
|
||||
label="Section of that page"
|
||||
value={section}
|
||||
onChange={onSectionChange}
|
||||
>
|
||||
<option value={WHOLE_PAGE}>The page as a whole</option>
|
||||
{sections.map((s) => (
|
||||
<option key={s.hash} value={s.hash}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable version of the picked location, for the thank-you panel.
|
||||
function describeLocation(page, section) {
|
||||
if (page === SITE_WIDE) return null;
|
||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||
(s) => s.hash === section,
|
||||
)?.label;
|
||||
return sectionLabel ? `${pageLabel} → ${sectionLabel}` : pageLabel;
|
||||
}
|
||||
|
||||
/* ── The form ────────────────────────────────────────────────── */
|
||||
|
||||
export default function FeedbackForm() {
|
||||
const [type, setType] = useState(null);
|
||||
const [page, setPage] = useState(SITE_WIDE);
|
||||
const [section, setSection] = useState(WHOLE_PAGE);
|
||||
const [message, setMessage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
// Honeypot: never shown, never filled by a person.
|
||||
const [website, setWebsite] = useState("");
|
||||
|
||||
// idle → sending → sent, or back to idle with an error to show.
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
|
||||
const sending = status === "sending";
|
||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!ready || sending) return;
|
||||
|
||||
setStatus("sending");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
await post("/feedback", {
|
||||
feedbackType: type,
|
||||
message: message.trim(),
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
pagePath: page === SITE_WIDE ? "" : page,
|
||||
sectionId: section, // server strips the leading '#'
|
||||
website,
|
||||
});
|
||||
setStatus("sent");
|
||||
} catch (error) {
|
||||
setStatus("idle");
|
||||
if (error instanceof ApiError) {
|
||||
setFieldErrors(error.fields ?? {});
|
||||
setFormError(
|
||||
error.fields
|
||||
? "Have another look at the highlighted fields."
|
||||
: error.message,
|
||||
);
|
||||
} else {
|
||||
// Network failure, offline, server down.
|
||||
setFormError("Couldn't reach the server. Try again in a moment.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setType(null);
|
||||
setPage(SITE_WIDE);
|
||||
setSection(WHOLE_PAGE);
|
||||
setMessage("");
|
||||
setName("");
|
||||
setEmail("");
|
||||
setWebsite("");
|
||||
setStatus("idle");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
if (status === "sent") {
|
||||
const where = describeLocation(page, section);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
|
||||
Thanks — we've got it
|
||||
</h3>
|
||||
{where && (
|
||||
<p className="mt-3 text-sm" style={{ color: MUTED }}>
|
||||
Filed against {where}.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
|
||||
{email
|
||||
? `We'll follow up at ${email} if we have questions.`
|
||||
: "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<TypePicker value={type} onChange={setType} />
|
||||
|
||||
<div className="mt-10">
|
||||
<LocationPicker
|
||||
page={page}
|
||||
section={section}
|
||||
onPageChange={setPage}
|
||||
onSectionChange={setSection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-10">
|
||||
<label
|
||||
htmlFor="feedback-message"
|
||||
className="text-base font-semibold text-[#26454c]"
|
||||
>
|
||||
Tell us more
|
||||
</label>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
What you expected to happen, and what happened instead, is the most
|
||||
useful thing you can give us.
|
||||
</p>
|
||||
<textarea
|
||||
id="feedback-message"
|
||||
rows={7}
|
||||
maxLength={MAX_CHARS}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I was looking for the summer retreat dates and…"
|
||||
aria-invalid={Boolean(fieldErrors.message)}
|
||||
aria-describedby={fieldErrors.message ? "feedback-message-error" : undefined}
|
||||
className={`mt-4 resize-y ${fieldClass} ${
|
||||
fieldErrors.message ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<FieldError id="feedback-message-error">{fieldErrors.message}</FieldError>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-xs tabular-nums"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
{message.length} / {MAX_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional contact details */}
|
||||
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">Your details</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave these blank and your feedback comes through anonymously. Fill
|
||||
them in only if you'd like a reply — we won't add you to any list.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-name"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={Boolean(fieldErrors.email)}
|
||||
aria-describedby={fieldErrors.email ? "feedback-email-error" : undefined}
|
||||
className={`mt-2 ${fieldClass} ${
|
||||
fieldErrors.email ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<FieldError id="feedback-email-error">{fieldErrors.email}</FieldError>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honeypot. Off-screen rather than display:none, since bots
|
||||
skip hidden inputs. Never announced, never tabbable. */}
|
||||
<div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden">
|
||||
<label htmlFor="feedback-website">Website</label>
|
||||
<input
|
||||
id="feedback-website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{formError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-8 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || sending}
|
||||
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
|
||||
>
|
||||
{sending ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
{!ready && !sending && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
Choose a type and write at least a sentence to send.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
240
src/pages/sections/OrgList-Card.tsx
Normal file
240
src/pages/sections/OrgList-Card.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — CARDS
|
||||
|
||||
A grid of organization cards. No toggle: this view is the grid,
|
||||
which is why the section that uses it declares no `views` in the
|
||||
page manifest and gets no control in its heading.
|
||||
|
||||
<OrgListCards kind="partner" pageLabel="Partner page" />
|
||||
|
||||
Where the vertical list expands in place, a card links out. The
|
||||
card carries as much as fits at a glance and the organization's
|
||||
own page carries the rest, so nothing here reads a `blocks`
|
||||
array — only the short card description.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* Wide enough for a logo beside two lines of text, narrow enough
|
||||
that three fit on a laptop. The grid drops a column rather than
|
||||
squeezing below this. */
|
||||
const CARD_MIN = "20rem";
|
||||
const GRID_MAX = "88rem";
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-14 w-14 object-contain rounded-xl shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-14 w-14 rounded-xl shrink-0 flex items-center justify-center font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||
const color = org.color || accent;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Tagline first, then where they are. Partners often have one and
|
||||
// not the other, so this is the line that's most likely to say
|
||||
// something useful.
|
||||
const subtitle = org.tagline || org.location_label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 h-full flex flex-col transition-transform duration-200 hover:scale-[1.01]"
|
||||
style={{ border: `1px solid ${color}`, background: "#ffffff" }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-800 leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm mt-0.5" style={{ color: MUTED }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right rather than in the footer: the footer holds
|
||||
outbound links, and the arrow goes somewhere different
|
||||
in kind — deeper into this site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{org.description?.length > 0 && (
|
||||
<p
|
||||
className="mt-4 text-sm leading-relaxed line-clamp-4"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{org.description[0]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the footer so buttons line up across a row
|
||||
however much text each card carries. */}
|
||||
<div className="mt-auto pt-5 flex flex-wrap gap-2">
|
||||
{org.website && (
|
||||
<a
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{org.email && (
|
||||
<a
|
||||
href={`mailto:${org.email}`}
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-12">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-5" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-5 items-stretch"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`,
|
||||
}}
|
||||
>
|
||||
{orgs.map(org => (
|
||||
<OrgCard
|
||||
key={org.id}
|
||||
org={org}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListCards({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Learn more",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided grid.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block
|
||||
title={title}
|
||||
orgs={sorted}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
accent={accent}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
907
src/pages/sections/OrgList-Map.tsx
Normal file
907
src/pages/sections/OrgList-Map.tsx
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommunity } from "../../data/chapters.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — MAP
|
||||
|
||||
Organizations placed on a tile grid: the map on the left, the
|
||||
list on the right, selecting on either side filtering both.
|
||||
|
||||
Filtered to chapters, and unlike the other sections that isn't
|
||||
a prop. A map only makes sense for organizations that sit
|
||||
somewhere, and it reads regions off the same data to colour the
|
||||
tiles — so "plot partners instead" isn't a filter change, it's a
|
||||
different component. Named for the view rather than the filter
|
||||
so that stays visible.
|
||||
|
||||
Why a tile grid rather than a geographic map: every state reads
|
||||
at the same size (so Rhode Island is as clickable as Texas), it
|
||||
stays legible on a phone, it needs no map library or GeoJSON,
|
||||
and a state split between two regions is just a tile painted in
|
||||
two colors. If you later want true geography, the swap point is
|
||||
<RegionMap> — everything else works off the data.
|
||||
|
||||
The tile layout comes from mapGrid.js; who paints what comes
|
||||
from the API. A state and the Canada band are the same shape
|
||||
now, a tile with a span, so the map is one loop.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const TILE = 100;
|
||||
const PAD = 5;
|
||||
const COLS = 13;
|
||||
const ROWS = 7;
|
||||
|
||||
// How wide the map + list block runs. The section heading above it
|
||||
// stays at max-w-6xl, so this deliberately breaks out past it.
|
||||
const CONTENT_MAX = "88rem";
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
const US_TITLE = "US Unity Regions";
|
||||
const INTL_TITLE = "International Unity Regions";
|
||||
|
||||
/* ── Body content ──────────────────────────────────────────────
|
||||
A chapter's description is ordered blocks rather than one
|
||||
string, so a heading or a list added later renders instead of
|
||||
vanishing. When organization and person pages arrive this should
|
||||
move to a shared component; small enough to live here until then.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Blocks({ blocks = [], color }) {
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3" style={{ color: INK }}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return (
|
||||
<h4 key={i} className="text-lg font-800" style={{ color }}>
|
||||
{block.text}
|
||||
</h4>
|
||||
);
|
||||
case "subheading":
|
||||
return (
|
||||
<h5 key={i} className="font-700">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="pl-3 italic"
|
||||
style={{ borderLeft: `2px solid ${color}` }}
|
||||
>
|
||||
{block.text}
|
||||
</blockquote>
|
||||
);
|
||||
case "list":
|
||||
case "links":
|
||||
return (
|
||||
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
||||
{block.items.map((item, j) => (
|
||||
<li key={j}>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color }}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
{item.detail && (
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{" "}
|
||||
— {item.detail}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "divider":
|
||||
return <hr key={i} style={{ borderColor: RULE }} />;
|
||||
default:
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{block.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tile ──────────────────────────────────────────────────────
|
||||
Every region painting this tile gets its own rect. A whole tile
|
||||
is one slice with no edge; a shared tile is two, each declaring
|
||||
which edge it sits on and how much it takes. Nothing here
|
||||
subtracts, and the order the slices arrive in doesn't change
|
||||
what's drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Tile({
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
width = size,
|
||||
label,
|
||||
slices = [],
|
||||
count = 0,
|
||||
selected,
|
||||
hovered,
|
||||
setSelected,
|
||||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}) {
|
||||
if (slices.length === 0) return null;
|
||||
|
||||
const primary = slices[0];
|
||||
const ids = slices.map(s => s.regionId);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
const opacity = count ? 1 : active ? 0.5 : 0.28;
|
||||
|
||||
// Clicking a shared tile cycles through its regions and then
|
||||
// clears, so every slice is reachable without a second control.
|
||||
const cycle = () => {
|
||||
onPick?.(code);
|
||||
const at = ids.indexOf(selected);
|
||||
setSelected(at === ids.length - 1 ? null : ids[at + 1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={cycle}
|
||||
onMouseEnter={() => setHovered(primary.regionId)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
<title>
|
||||
{AREA_NAMES[code] || code} — {slices.map(s => s.name).join(" / ")}
|
||||
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
|
||||
</title>
|
||||
|
||||
<clipPath id={clipId}>
|
||||
<rect x={x} y={y} width={width} height={size} rx={14} />
|
||||
</clipPath>
|
||||
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{slices.map(slice => {
|
||||
const h = slice.edge ? size * slice.share : size;
|
||||
const top = slice.edge === "bottom" ? y + size - h : y;
|
||||
return (
|
||||
<rect
|
||||
key={slice.regionId}
|
||||
x={x}
|
||||
y={top}
|
||||
width={width}
|
||||
height={h}
|
||||
fill={slice.color}
|
||||
fillOpacity={opacity}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + size / 2 + 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
</text>
|
||||
|
||||
{count > 0 && (
|
||||
<circle
|
||||
cx={x + width - 14}
|
||||
cy={y + 14}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.9}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||
const size = TILE - PAD * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
|
||||
className="w-full h-auto"
|
||||
role="group"
|
||||
aria-label="Chapters by Unity region"
|
||||
>
|
||||
{/* States and bands are the same shape — a tile with a span —
|
||||
so this is one loop rather than two. */}
|
||||
{AREAS.map(area => (
|
||||
<Tile
|
||||
key={area.code}
|
||||
code={area.code}
|
||||
label={area.isState ? area.code : area.name}
|
||||
x={(area.col - 1) * TILE + PAD}
|
||||
y={(area.row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
width={area.span * TILE - PAD * 2}
|
||||
fontSize={area.isState ? 34 : 38}
|
||||
slices={slices[area.code] ?? []}
|
||||
count={chapterCounts[area.code] ?? 0}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === region.id || hovered === region.id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(region.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-pressed={selected === region.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${region.color}`,
|
||||
background: on ? region.color : "transparent",
|
||||
color: on ? "#ffffff" : region.color,
|
||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : region.color }}
|
||||
/>
|
||||
{region.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||
const { selected, setSelected } = props;
|
||||
|
||||
// A region is on the map if it paints a tile. West Central used
|
||||
// to need a hardcoded exception here because its states arrived
|
||||
// only through SPLITS; it has ordinary rows now, so the exception
|
||||
// is gone.
|
||||
const onMap = region => onMapIds.has(region.id);
|
||||
const us = domestic.filter(onMap);
|
||||
const intl = international.filter(onMap);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{US_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{us.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px my-4" style={{ background: RULE }} />
|
||||
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{INTL_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{intl.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="py-1.5 px-3 rounded-lg text-sm font-700 underline"
|
||||
style={{ color: FALLBACK_COLOR }}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionBlock({
|
||||
region,
|
||||
chapters,
|
||||
subtext,
|
||||
selected,
|
||||
setSelected,
|
||||
setHovered,
|
||||
indent,
|
||||
regionRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
const on = selected === region.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(on ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full flex items-baseline gap-2 text-left py-2"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
{region.name}
|
||||
</h4>
|
||||
<span className="text-sm ml-auto" style={{ color: MUTED }}>
|
||||
{chapters.length || "—"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{subtext && (
|
||||
<p className="text-sm mb-2 ml-5 leading-snug" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm ml-5 mb-4" style={{ color: MUTED }}>
|
||||
No chapters yet — interested in starting one?
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3 flex items-start gap-3"
|
||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700">{c.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{c.location_label}
|
||||
{c.meets ? ` · ${c.meets}` : ""}
|
||||
</p>
|
||||
{(c.website || c.email) && (
|
||||
<p className="text-sm mt-1 flex gap-4">
|
||||
{c.website && (
|
||||
<a
|
||||
href={c.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
{c.email && (
|
||||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orgPath(c) && (
|
||||
<ArrowLink
|
||||
to={orgPath(c)}
|
||||
label={`${c.name} — chapter page`}
|
||||
color={region.color}
|
||||
size="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
GRID VIEW
|
||||
One grid per region. Clicking a card's arrow opens a detail
|
||||
panel directly above that region's grid, framed in the region
|
||||
color. Selecting another card swaps the panel's contents.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Logo, or the organization's initials when there's no file. */
|
||||
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt={org.name}
|
||||
onError={() => setFailed(true)}
|
||||
className={`${size} object-contain rounded-xl shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-label={org.name}
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: open ? `${color}14` : "transparent",
|
||||
}}
|
||||
>
|
||||
<OrgLogo org={chapter} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700 leading-tight">{chapter.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{chapter.location_label}
|
||||
</p>
|
||||
{chapter.meets && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{chapter.meets}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two controls, two destinations: the chevron opens the
|
||||
detail panel in place, the arrow leaves for the chapter's
|
||||
own page. Keeping them distinct beats one control that
|
||||
does different things depending on where you click. */}
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
|
||||
className="h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{orgPath(chapter) && (
|
||||
<ArrowLink
|
||||
to={orgPath(chapter)}
|
||||
label={`${chapter.name} — chapter page`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({ chapter, region, onClose }) {
|
||||
/* "Led by" comes from affiliations rather than a text field, so
|
||||
it lists real people and stays empty until they exist. */
|
||||
const leads = (chapter.leadership ?? [])
|
||||
.map(person =>
|
||||
person.title ? `${person.display_name} (${person.title})` : person.display_name
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const rows = [
|
||||
["Region", region.name],
|
||||
["Where", chapter.venue],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", leads],
|
||||
["Since", chapter.started],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
<p style={{ color: FALLBACK_COLOR }}>{chapter.location_label}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2">
|
||||
<dt className="shrink-0" style={{ color: MUTED }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-700" style={{ color: INK }}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{orgPath(chapter) && (
|
||||
<Link
|
||||
to={orgPath(chapter)}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ background: region.color, color: "#ffffff" }}
|
||||
>
|
||||
Chapter page
|
||||
</Link>
|
||||
)}
|
||||
{chapter.website && (
|
||||
<a
|
||||
href={chapter.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Visit site
|
||||
</a>
|
||||
)}
|
||||
|
||||
{chapter.email && (
|
||||
<a
|
||||
href={`mailto:${chapter.email}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = regions
|
||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||
.filter(({ list }) => list.length > 0);
|
||||
const empty = regions.filter(region => chaptersIn(region.id).length === 0);
|
||||
const openChapter = chapters.find(c => c.id === openId) || null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ region, list }) => {
|
||||
const subtext = subtextFor(region);
|
||||
return (
|
||||
<section key={region.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</h3>
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
{subtext && (
|
||||
<p className="text-sm mb-5 ml-6" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Detail panel sits above this region's grid, and only
|
||||
when the open chapter belongs to this region. */}
|
||||
{openChapter && openChapter.region_id === region.id && (
|
||||
<ChapterDetail
|
||||
chapter={openChapter}
|
||||
region={region}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-4 items-start"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
|
||||
}}
|
||||
>
|
||||
{list.map(c => (
|
||||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={region.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{empty.length > 0 && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
No chapters yet in {empty.map(r => r.name).join(", ")} — interested in
|
||||
starting one?
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function OrgMapToggle({ 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 chapters are displayed"
|
||||
>
|
||||
{[
|
||||
["map", "Map"],
|
||||
["grid", "Grid"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
aria-pressed={view === id}
|
||||
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
regions,
|
||||
regionAreas,
|
||||
domestic,
|
||||
international,
|
||||
virtual,
|
||||
chapters,
|
||||
chaptersIn,
|
||||
slices,
|
||||
chapterCounts,
|
||||
regionsForArea,
|
||||
subtextFor,
|
||||
} = useCommunity();
|
||||
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||
|
||||
// The list scrolls itself to whatever the map or legend points at.
|
||||
const listRef = useRef(null);
|
||||
const regionRefs = useRef({});
|
||||
const chapterRefs = useRef({});
|
||||
|
||||
const scrollListTo = el => {
|
||||
const box = listRef.current;
|
||||
if (!box || !el) return;
|
||||
// Only when the list is its own scroll area (lg and up). Below
|
||||
// that it's stacked under the map and scrolling it would fight
|
||||
// the page.
|
||||
if (box.scrollHeight <= box.clientHeight) return;
|
||||
box.scrollTo({ top: el.offsetTop - 8, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Hovering or selecting a region brings that block into view.
|
||||
useEffect(() => {
|
||||
const id = hovered || selected;
|
||||
if (id) scrollListTo(regionRefs.current[id]);
|
||||
}, [hovered, selected]);
|
||||
|
||||
// Clicking a tile jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickArea = code => {
|
||||
const chapter = chapters.find(c => c.area_code === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
}
|
||||
const region = regionsForArea(code)[0];
|
||||
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||
};
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && regions.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading chapters" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && regions.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· Chapters couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "grid") {
|
||||
return (
|
||||
<ChapterGrid
|
||||
regions={regions}
|
||||
chapters={chapters}
|
||||
chaptersIn={chaptersIn}
|
||||
subtextFor={subtextFor}
|
||||
openId={openId}
|
||||
setOpenId={setOpenId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||
|
||||
const block = (region, indent) => (
|
||||
<RegionBlock
|
||||
key={region.id}
|
||||
region={region}
|
||||
chapters={chaptersIn(region.id)}
|
||||
subtext={subtextFor(region)}
|
||||
indent={indent}
|
||||
regionRefs={regionRefs}
|
||||
chapterRefs={chapterRefs}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
/* Wider than the section heading above it — the map needs the
|
||||
room. CONTENT_MAX is the knob; drop it toward 72rem to pull
|
||||
the whole block back in line with the heading. */
|
||||
shell(
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<RegionMap
|
||||
slices={slices}
|
||||
chapterCounts={chapterCounts}
|
||||
onPick={pickArea}
|
||||
{...shared}
|
||||
/>
|
||||
<Legend
|
||||
domestic={domestic}
|
||||
international={international}
|
||||
onMapIds={onMapIds}
|
||||
{...shared}
|
||||
/>
|
||||
<p className="text-sm mt-4" style={{ color: MUTED }}>
|
||||
A filled tile means a chapter meets there; a two-tone tile is a
|
||||
state shared by two Unity regions. Select a region to filter the
|
||||
list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* List — h-0 + min-h-full makes this column take its height
|
||||
from the map column rather than the other way round, so a
|
||||
long list scrolls instead of stretching the section. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{US_TITLE}
|
||||
</h3>
|
||||
{domestic.map(r => block(r, true))}
|
||||
|
||||
<h3
|
||||
className="text-xl font-800 mt-6 mb-1 pt-4"
|
||||
style={{ color: FALLBACK_COLOR, borderTop: `1px solid ${RULE}` }}
|
||||
>
|
||||
{INTL_TITLE}
|
||||
</h3>
|
||||
{international.map(r => block(r, true))}
|
||||
|
||||
<div className="mt-6 pt-4" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
{virtual.map(r => block(r, false))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { useId, useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — VERTICAL
|
||||
|
||||
A stack of organizations, each collapsed to a line and
|
||||
expandable for the rest. Every row carries two ways out: an
|
||||
arrow through to that organization's page on this site, and a
|
||||
labelled button out to its own site when it has one.
|
||||
|
||||
Nothing here is region-specific. It asks for a kind and renders
|
||||
what comes back, so the same section lists partners or chapters
|
||||
by changing one prop:
|
||||
|
||||
<OrgListVertical kind="partner" title="Our Partners" />
|
||||
|
||||
<OrgListVertical
|
||||
kind="region"
|
||||
groups={[
|
||||
{ key: "domestic", title: "US Unity Regions" },
|
||||
{ key: "international", title: "International Unity Regions" },
|
||||
]}
|
||||
groupBy={org => org.details?.scope}
|
||||
/>
|
||||
|
||||
Kind-specific extras (the areas a region covers, the chapters
|
||||
inside it) come from `details` and render only when present, so
|
||||
a partner row simply doesn't have them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* ── Row button ───────────────────────────────────────────────
|
||||
Deliberately a sibling of the summary button rather than inside
|
||||
it — a link nested in a button is invalid, and a screen reader
|
||||
announces the whole row as one confused control.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||
return (
|
||||
<As
|
||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-8 w-8 object-contain shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (org.color) {
|
||||
return (
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-8 w-8 rounded-lg shrink-0 flex items-center justify-center text-xs font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
const color = org.color || FALLBACK_COLOR;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Region extras. Absent for every other kind, and the JSX below
|
||||
// skips them rather than rendering empty headings.
|
||||
const areas = areasSentence(org.details?.areas ?? []);
|
||||
const chapterCount = (org.details?.chapters ?? []).length;
|
||||
const note = org.details?.map_note;
|
||||
|
||||
/* Collapsed, a row says who it is and nothing else — the areas
|
||||
sentence runs long and lives in the panel, where it appears
|
||||
exactly once rather than in both places. */
|
||||
const summary = org.tagline;
|
||||
|
||||
// Something has to be behind the chevron or opening it does nothing.
|
||||
const hasPanel =
|
||||
org.description?.length > 0 || areas || note || org.links?.length > 0;
|
||||
|
||||
return (
|
||||
<div className="py-3" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className="flex-1 min-w-0 flex items-center gap-3 text-left py-1"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ol-chevron h-4 w-4 shrink-0"
|
||||
style={{ color, transform: open ? "rotate(90deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<span className="min-w-0">
|
||||
<span className="block font-800 text-lg leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</span>
|
||||
{summary && (
|
||||
<span className="block text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{chapterCount > 0 && (
|
||||
<span
|
||||
className="ml-auto text-sm shrink-0"
|
||||
style={{ color: MUTED }}
|
||||
title={`${chapterCount} chapter${chapterCount > 1 ? "s" : ""}`}
|
||||
>
|
||||
{chapterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{org.website && (
|
||||
<RowButton
|
||||
as="a"
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</RowButton>
|
||||
)}
|
||||
|
||||
{/* Last in the row, so the arrow lines up down the right
|
||||
edge whether or not a row has an outbound site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 0fr → 1fr animates to the panel's real height, so nobody
|
||||
has to guess a max-height that's wrong the moment a
|
||||
description grows. */}
|
||||
<div
|
||||
id={panelId}
|
||||
className="ol-panel"
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pt-2 pb-3 pl-10 pr-2 flex flex-col gap-3">
|
||||
{org.description?.map((text, i) => (
|
||||
<p key={i} className="leading-relaxed" style={{ color: INK }}>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{areas && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
<span className="font-700">Covers</span> {areas}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{note && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasPanel && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
More about this region soon.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{org.links?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{org.links.map(link => (
|
||||
<RowButton
|
||||
key={link.url}
|
||||
as="a"
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{link.label}
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{orgs.map(org => (
|
||||
<OrgRow key={org.id} org={org} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListVertical({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Region page",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided list.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block title={title} orgs={sorted} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue