v1.2 - added react router

This commit is contained in:
Zaldimmar 2026-09-25 02:34:15 -05:00
parent c83ff25a36
commit b0fba52c0e
28 changed files with 4318 additions and 993 deletions

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><!-- site:title --></title> <title><!-- site:title --></title>
<script src="https://js.churchcenter.com/modal/v1"></script>
<!-- site:head-end --> <!-- site:head-end -->
</head> </head>
<body> <body>

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,715 +1,37 @@
import { useState, useEffect } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import nguLogo from "@/NGU_Logo.svg"; import Layout from "./components/Layout.tsx";
import fallLogo from "@/Fall Logo.svg"; import Home from "./pages/Home.tsx";
import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg"; import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx";
{/* SVGs */} import Feedback from "./pages/Feedback.tsx";
const DoveSVG = ({ className = "" }: { className?: string }) => ( import Giving from "./pages/Giving.tsx";
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
<defs id="defs1" />
<g id="layer1" transform="translate(-70.490069,-117.83965)">
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
</g>
</g>
</svg>
);
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" 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>
);
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
);
{/* Link Tables */}
const Social_Links = [
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
]
const PAGE_LINKS = [
{ label: "Home", tab: "main" },
{ label: "Other", tab: "other" },
{ label: "Resources", tab: "resources" },
];
const PAGE_SECTIONS = {
main: [
{ label: "#About", href: "#about" },
{ label: "#Events", href: "#events" },
{ label: "#Connect", href: "#connect" },
],
other: [],
resources: [],
};
const Footer_Links = [
{ label: "Privacy Policy", href: "#"},
{ label: "Terms of Service", href: "#"},
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
]
const EVENTS = [
{
id: "spring-2025",
title: "Spring Retreat 2026",
theme: "Altering Intertia",
date: "March/April 2026",
location: "Unity Village, MO",
image: fallLogo, // e.g. springLogo
color: "#f1c2fe",
gradient:
"linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
desc_a:
"A weekend of connection, workshops, and community for young adults across the Unity movement.",
desc_b: null,
status: "past",
links: [
],
},
{
id: "fall-retreat-2026",
title: "Fall Retreat 2026",
theme: "Consciousness Creates",
date: "November 12-15th, 2026",
location: "Unity Village, MO",
image: fallLogo,
color: "#b89421",
gradient:
"linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
desc_a:
"Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
desc_b: "Registration starting at $150, and $75 lodging cost.",
status: "upcoming",
links: [
{ label: "Register Now!", link: "https://ngu.churchcenter.com/registrations/events/3761999" },
{ label: "Scholarship Application", link: "https://ngu.churchcenter.com/people/forms/1261992" },
{ label: "Volunteer", link: "https://ngu.churchcenter.com/people/forms/1176908" },
],
},
{
id: "spring-recharge-2027",
title: "Spring Recharge 2027",
theme: "TBD",
date: "March 6th, 2027",
location: "Online",
image: null, // e.g. eventLogo
color: "#138ba0",
gradient:
null,
desc_a:
"One-day online event to reconnect in the spring.",
desc_b: null,
status: "upcoming",
links: [
],
},
{
id: "spring-service-2027",
title: "Service Week 2027",
theme: "Leadership & Service",
date: "April 4-9th, 2027",
location: "Unity Village, MO",
image: null, // e.g. eventLogo
color: "#138ba0",
gradient:
null,
desc_a:
"Join us at beautiful Unity Village for a week of leadership development and service projects.",
desc_b: null,
status: "upcoming",
links: [
],
}
];
const START_INDEX = (() => {
const i = EVENTS.findIndex(e => e.status === "upcoming");
return i === -1 ? EVENTS.length - 1 : i;
})();
const TEAL = "#138ba0";
const CARD = "min(48rem, 90vw)"; // the card itself — your original max-w-3xl
const GAP = "5rem"; // space between cards ← this is your knob
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})))`;
{/* Functions */}
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
return (
<>
{text.split("").map((char, i) => (
<span
key={i}
className="float-anim"
style={{ animationDelay: `${-i * step}s` }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</>
);
}
import Privacy from "./pages/Privacy.tsx";
import Terms from "./pages/Terms.tsx";
import NotFound from "./pages/NotFound.tsx";
export default function App() { export default function App() {
{/* NAV functions*/}
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState("main");
const [activeSection, setActiveSection] = useState(null);
const sections = PAGE_SECTIONS[activeTab] ?? [];
const activePage = PAGE_LINKS.find((p) => p.tab === activeTab);
const goToPage = (tab) => {
setActiveTab(tab);
setActiveSection(null);
setMobileMenuOpen(false);
window.scrollTo({ top: 0 });
};
// Highlight the section currently in view, so the second tier
// visibly tracks position within the page.
useEffect(() => {
if (sections.length === 0) return;
const targets = sections
.map((s) => document.querySelector(s.href))
.filter(Boolean);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible) setActiveSection(`#${visible.target.id}`);
},
{ rootMargin: "-120px 0px -60% 0px", threshold: 0 }
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
}, [activeTab]);
{/* Other useState consts and Functions*/}
const [showCalendar, setShowCalendar] = useState(false);
const [index, setIndex] = useState(START_INDEX);
const event = EVENTS[index];
const isPast = event.status === "past";
const currentColor = event.color || TEAL;
const prev = () => setIndex(i => Math.max(0, i - 1));
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
const arrowStyle = enabled => ({
border: `1px solid ${TEAL}`,
background: "rgba(255,255,255,0.6)",
color: enabled ? TEAL : "#b8c6c9",
cursor: enabled ? "pointer" : "default",
opacity: enabled ? 1 : 0.4,
});
{/* Start of Main Content*/}
return ( return (
<div className="min-h-screen overflow-x-hidden"> <BrowserRouter>
<Routes>
{/* ── NAV ─────────────────────────────────────────────── */} {/* Layout renders the nav once and swaps page content into <Outlet /> */}
<header className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md"> <Route element={<Layout />}>
<Route index element={<Home />} />
{/* Tier 1 — site navigation */} <Route path="retreats" element={<Retreats />} />
<nav <Route path="community" element={<Community />} />
className="flex items-center justify-between px-6 py-4" <Route path="leadership" element={<Leadership />} />
aria-label="Site" <Route path="resources" element={<Resources />} />
style={{ background: "rgba(0, 69, 82,0.92)" }} <Route path="feedback" element={<Feedback />} />
> <Route path="give" element={<Giving />} />
<div className="flex items-center gap-8"> <Route path="privacy" element={<Privacy />} />
<a href="/" aria-label="Next Generation of Unity home" className="inline-block"> <Route path="terms" element={<Terms />} />
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" /> <Route path="*" element={<NotFound />} />
</a> </Route>
</Routes>
<div className="hidden md:flex items-center gap-6"> </BrowserRouter>
{PAGE_LINKS.map((page) => (
<button
key={page.tab}
onClick={() => goToPage(page.tab)}
aria-current={activeTab === page.tab ? "page" : undefined}
className={`text-base pb-1 transition-colors duration-200 border-b-2 ${
activeTab === page.tab
? "text-white font-bold border-[#aac992]"
: "text-white/75 hover:text-white font-bold border-transparent"
}`}
>
{page.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-8">
<a
href="https://ngu.churchcenter.com/giving"
className="hidden md:inline-block px-5 py-2 rounded-full text-sm text-white transition-all duration-200 hover:scale-105 font-bold leading-relaxed"
style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}
>
Give
</a>
<button
className="md:hidden text-white p-2"
aria-label="Open menu"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all" />
<div className="w-6 h-0.5 bg-white mb-1.5" />
<div className="w-6 h-0.5 bg-white" />
</button>
</div>
</nav>
{/* Tier 2 — sections of the current page */}
{sections.length > 0 && (
<nav
className="hidden md:flex items-center justify-end gap-6 px-6 py-2"
aria-label={`On this page: ${activePage?.label}`}
style={{
background: "rgba(0, 47, 57, 0.92)",
borderBottom: "1px solid rgba(45,200,224,0.15)",
}}
>
{/* Anchors the row to its parent page */}
<span className="mr-auto text-xs text-white/50">
{activePage?.label}
</span>
{sections.map((section) => (
<a
key={section.href}
href={section.href}
aria-current={activeSection === section.href ? "location" : undefined}
className={`text-sm transition-colors duration-200 ${
activeSection === section.href
? "text-[#aac992]"
: "text-white/70 hover:text-[#aac992]"
}`}
>
{section.label}
</a>
))}
</nav>
)}
</header>
{/* Keeps page content clear of the fixed header */}
<div aria-hidden className={sections.length > 0 ? "h-[6.75rem]" : "h-[4.5rem]"} />
{/* Mobile menu */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center overflow-y-auto py-16"
style={{ background: "rgba(7,61,74,0.97)" }}
>
<button
className="absolute top-5 right-6 text-white text-3xl"
aria-label="Close menu"
onClick={() => setMobileMenuOpen(false)}
>
×
</button>
<div className="w-64">
{PAGE_LINKS.map((page) => {
const pageSections = PAGE_SECTIONS[page.tab] ?? [];
const isActive = activeTab === page.tab;
return (
<div key={page.tab}>
<button
onClick={() => goToPage(page.tab)}
aria-current={isActive ? "page" : undefined}
className={`block w-full text-left text-2xl font-bold py-3 transition-colors ${
isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
}`}
>
{page.label}
</button>
{/* Sections nest under the page they belong to */}
{isActive && pageSections.length > 0 && (
<div
className="ml-3 pl-4 mb-2 flex flex-col"
style={{ borderLeft: "1px solid rgba(45,200,224,0.35)" }}
>
{pageSections.map((section) => (
<a
key={section.href}
href={section.href}
className="text-white/80 text-lg py-2 hover:text-[#10d48a] transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{section.label}
</a>
))}
</div>
)}
</div>
);
})}
</div>
<a
href="https://ngu.churchcenter.com/giving"
className="mt-6 px-8 py-3 rounded-full text-white text-lg font-bold"
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)" }}
onClick={() => setMobileMenuOpen(false)}
>
Give
</a>
</div>
)}
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
<section id="hero" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
{/* Floating doves */}
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
<div className="relative z-10 max-w-4xl mx-auto px-6">
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
Next Generation<br />
<span className="grad-hero-text">
<WaveText text="of Unity" step={0.1} />
</span>
</h1>
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
</p>
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
We are the future of the Unity Movement.
</p>
<div className="flex flex-wrap gap-4 justify-center">
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
Follow Us on Instagram
</a>
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
Attend a Retreat
</a>
</div>
</div>
</section>
{/* #About ─────────────────────────────────────── */}
<section id="about" className="py-24 px-6" style={{ background: "#f0fcfd" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
].map((card) => (
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
<div className="text-4xl mb-4">{card.icon}</div>
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* #Events ─────────────────────────────────────────────── */}
<section id="events" className="py-24 overflow-hidden" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
{isPast ? "Past Events" : "Upcoming Events"}
</h2>
</div>
</div>
{/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
<div className="relative mb-6">
{/* Masked viewport: everything outside the fade gradient is invisible */}
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
{/* Sliding track */}
<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;
const past = ev.status === "past";
const color = ev.color || TEAL;
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}
>
<div
className="rounded-3xl text-black overflow-hidden shadow-2xl h-full"
style={{
border: `1px solid ${color}`,
background: ev.gradient,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: active ? "auto" : "none",
}}
>
<div className="p-10">
<div className="grid grid-cols-1 md:grid-cols-3 mb-4">
<div className="md:col-span-2">
<img
src={nguLogo_WhiteBG}
alt="Next Generation of Unity"
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>
)}
<p className="text-2xl">{ev.date}</p>
<p className="text-2xl">{ev.location}</p>
</div>
<div className="md:col-span-1 flex justify-end items-start">
{ev.image && (
<img src={ev.image} alt={ev.title} className="h-60" />
)}
</div>
</div>
{ev.desc_a && <p className="mb-2 leading-relaxed">{ev.desc_a}</p>}
{ev.desc_b && <p className="leading-relaxed">{ev.desc_b}</p>}
{ev.links.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-8">
{ev.links.map(item => (
<a
key={item.label}
href={item.link}
className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center"
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
</div>
) : (
past ? (
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
This event has concluded, thank you to everyone who joined us!
</p>
) : (
<>
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
Registration has not opened yet, follow our instagram for more details.
</p>
<a href="https://instagram.com/nextgenerationunity" target="_blank" rel="noopener noreferrer" className="ig-link mt-4 w-fit mx-auto flex items-center justify-center gap-3 py-3 px-4 rounded-xl font-700 transition-all duration-200 hover:scale-[1.02]" style={{ border: `1px solid ${color}`, color }}>
<InstagramIcon id={`ig-${ev.id}`} />
@nextgenerationunity
</a>
</>
)
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Arrows — outside the masked element so they never fade */}
<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>
<div className="max-w-6xl mx-auto px-6">
{/* Dot indicators */}
<div className="flex justify-center gap-2 mb-10">
{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 ? currentColor : "#b8c6c9",
}}
/>
))}
</div>
<p className="text-center text-[#138ba0] font-600 text-sm">
· More events coming soon, stay connected for announcements ·
</p>
</div>
</section>
{/* #Connect -------------------------------------------- */}
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
Ready to Connect?
</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{[
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
].map(item => (
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
<div>
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
</div>
{/* Arrow */}
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
</svg>
</a>
))}
</div>
<div className="mt-10 text-center">
<div className="inline-flex flex-wrap justify-center gap-4">
<a
href="https://ngu.churchcenter.com/calendar?view=gallery"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105"
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}
>
📅 View NGU Calendar
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button
onClick={() => setShowCalendar(!showCalendar)}
className="inline-flex items-center gap-2 px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105"
style={{ border: "2px solid #138ba0", color: "#138ba0", background: "transparent", fontFamily: "Outfit,sans-serif" }}
>
{showCalendar ? "▲ Hide Calendar" : "▼ Show Calendar Here"}
</button>
</div>
{showCalendar && (
<div className="mt-8 mx-auto max-w-4xl rounded-2xl shadow-lg p-6" style={{ border: "2px solid #138ba0" }}>
<iframe
src="https://ngu.churchcenter.com/calendar?embed=true&view=month"
title="NGU Calendar"
className="w-full planning-center-calender-embed"
style={{ height: "700px", border: "none" }}
/>
</div>
)}
</div>
</div>
</section>
{/* ── FOOTER ─ Using #042f3a for BG ───────────────────────────────────── */}
<footer className="py-16 px-6" style={{ background: "rgba(0, 47, 58, 0.92)" }}>
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
<div className="flex items-center gap-4">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</div>
<div className="flex gap-4">
{Social_Links.map(({label, href, Icon}) => (
<a key={label} href={href} className="footer-icons w-10 h-10 rounded-full flex items-center justify-center text-white/70 transition-colors" style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}>
{label == "Instagram" ? (<InstagramIcon id={`ig-footer`} />) : (<Icon />)}
</a>
))}
</div>
</div>
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
<div className="flex gap-6">
{Footer_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white/40 hover:text-[#aac992] text-sm transition-colors">{link.label}</a>
))}
</div>
</div>
</div>
</footer>
</div>
); );
} }

View file

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 221 KiB

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="403.64276mm"
height="86.755943mm"
viewBox="0 0 403.64273 86.755943"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<g
id="layer10"
style="display:inline"
transform="translate(-344.28592,167.60401)">
<g
id="g3">
<circle
style="display:inline;fill:#008da9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.634488;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path205602-8-3-1"
cx="403.82111"
cy="-121.75806"
r="40.889923" />
<path
id="path1887-1-3-7-7-6-2"
style="color:#000000;display:inline;fill:#008da9;fill-opacity:1;stroke:none;stroke-width:2.284;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 443.45159,-167.57122 c -8.63995,3.71591 -17.05927,7.97953 -25.85742,11.27148 -3.76442,1.391 -7.68514,2.52606 -11.12305,4.64648 -3.58916,2.42042 -5.95825,6.25942 -7.35135,10.29228 -1.23335,3.0863 -2.32136,6.17712 -3.84982,9.11983 -3.67393,-2.17848 -7.69746,-4.08371 -12.04108,-4.24843 -4.09333,-0.39358 -8.26381,0.55262 -11.85512,2.53592 -6.41916,3.13465 -11.92468,7.82515 -18.56868,10.51862 -2.73797,1.21486 -5.51687,2.3331 -8.27926,3.49077 -0.88252,2.47734 0.77854,5.31191 3.20508,6.10351 4.27199,1.7128 8.98018,1.56772 13.49433,1.35771 4.71612,-0.31488 9.32779,-1.47771 13.7933,-2.98449 4.28568,-1.20657 8.61172,-2.23873 13.00143,-3.03728 -2.64145,2.85774 -6.27041,4.39222 -9.272,6.78523 -0.9862,0.76271 -1.85771,1.89939 -1.64657,3.21845 0.1709,1.85512 1.7211,3.08775 2.9593,4.29677 2.6421,2.35246 5.95831,4.07918 9.49722,4.487017 1.35103,0.106385 2.71085,-0.642417 3.25697,-1.900747 1.43582,-2.68121 2.25426,-5.67671 4.35603,-7.93655 1.20099,-1.4152 3.08751,-1.77888 4.83141,-2.01081 3.79664,-0.62592 7.50391,-2.47133 9.93534,-5.50938 1.37471,-1.65305 2.10201,-3.71602 2.52235,-5.79656 0.1379,-0.6722 1.04486,-0.68506 1.58622,-0.85974 1.78218,-0.35339 3.91283,-0.77776 4.83392,-2.54366 0.89776,-1.84635 -0.0715,-4.04503 -1.61189,-5.21854 -2.41639,-1.86664 -5.65021,-3.01579 -8.63111,-2.61499 2.2258,-1.54005 4.74178,-2.58917 7.19752,-3.70372 6.31792,-2.71081 13.11161,-4.94567 18.40937,-9.54069 3.73885,-3.20962 6.79577,-7.24801 8.78271,-11.76096 0.80138,-2.0522 1.44232,-4.33553 0.95805,-6.5376 -0.3398,-1.04637 -1.34059,-2.15906 -2.5332,-1.91992 z" />
<path
id="path92116-3-2-7-1-4"
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.222835;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 401.4397,-85.357733 -5.07733,3.7999 c 1.40469,0.28813 3.37038,0.49431 4.1477,0.56482 0,0 13.4726,-8.14749 21.27399,-14.00284 7.80139,-5.855347 13.28514,-13.045137 15.64699,-18.030857 4.23462,-8.93904 5.76755,-17.18121 4.93866,-21.94278 -2.3532,12.13055 -6.84548,20.1394 -8.91525,22.66728 -2.77658,3.39114 -7.3818,10.79482 -14.1263,15.035397 -8.02485,5.04559 -16.87157,10.25674 -17.88846,11.90908 z" />
<path
fill="#519259"
d="m 401.25566,-85.224563 c 2.11431,-7.90893 7.29014,-19.728807 13.53049,-25.077287 3.15174,-2.74769 6.75532,-5.34691 11.45401,-7.64618 4.36158,-2.05194 6.70709,-3.73973 9.19688,-6.81027 3.40267,-3.94053 5.90574,-7.35199 6.96383,-10.47421 0.22347,8.84634 -6.48806,19.37766 -9.02864,23.54668 -6.2265,9.41606 -10.0147,12.608287 -17.50625,17.196147 -4.38236,2.60687 -9.65678,5.68615 -14.61032,9.26512 z"
fill-opacity="1"
fill-rule="nonzero"
id="path184-6-7-6-9-3-2"
style="display:inline;fill:#358a0d;fill-opacity:1;stroke:none;stroke-width:0.625031;stroke-opacity:1" />
<path
style="color:#000000;display:inline;fill:#ffffff;stroke:none;stroke-width:2.284;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 358.52422,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z"
id="path1887-1-3-7-7-6" />
<path
fill="#519259"
d="m 412.95849,-81.884143 c 4.58067,-1.05723 7.7275,-1.9948 13.67931,-5.91343 5.95181,-3.91863 10.62995,-9.54813 13.02992,-14.097387 2.39997,-4.54926 4.34384,-10.28218 4.83384,-15.81643 0.49,-5.53425 0.14775,-11.25197 -2.10696,-17.66367 0.15927,9.10203 -2.7414,15.06092 -4.17879,18.80503 -1.43739,3.74411 -4.80196,9.50687 -8.2717,13.57264 -3.46184,4.223207 -7.82036,7.647487 -12.67981,10.383567 -5.38026,3.05578 -13.21504,8.4585 -16.7484,11.61577 4.01135,0.4226 9.12694,-0.10316 12.44259,-0.88609 z"
fill-opacity="1"
fill-rule="nonzero"
id="path182-2-6-6-7-2-2"
style="display:inline;fill:#358a0d;fill-opacity:1;stroke:none;stroke-width:0.717458;stroke-opacity:1" />
</g>
<g
id="g40616-9"
transform="translate(13.096206,-216.39844)"
style="display:inline">
<g
id="g2-4">
<text
xml:space="preserve"
style="font-size:34.109px;line-height:1.25;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;display:inline;stroke-width:0.852729"
x="451.59149"
y="89.758339"
id="text156858-3-8"><tspan
id="tspan156856-8-82"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:34.109px;font-family:Poppins;-inkscape-font-specification:'Poppins, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#008da9;fill-opacity:1;stroke-width:0.852729"
x="451.59149"
y="89.758339">Next Generation</tspan></text>
<text
xml:space="preserve"
style="font-size:33.8136px;line-height:1.25;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;display:inline;stroke-width:0.845345"
x="469.26224"
y="119.08144"
id="text156858-3-1-4"><tspan
id="tspan156856-8-8-5"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:33.8136px;font-family:Poppins;-inkscape-font-specification:'Poppins, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#008da9;fill-opacity:1;stroke-width:0.845345"
x="469.26224"
y="119.08144">of Unity</tspan></text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.4 KiB

21
src/bannerConfig.js Normal file
View file

@ -0,0 +1,21 @@
// Site-wide announcement banner. Set `enabled: false` to hide it entirely.
// Bump `id` whenever the message changes — that re-shows the banner to
// people who dismissed the previous one.
export const SITE_BANNER = {
enabled: true,
id: "feedback-2026-09",
// Text renders in order. A part with `href` becomes a link.
content: [
{ text: "This site is brand new, " },
{
text: "please tell us what you think",
href: "/feedback#website",
external: true,
},
{ text: "." },
],
dismissible: true,
};

64
src/components/Banner.tsx Normal file
View file

@ -0,0 +1,64 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { SITE_BANNER } from "../bannerConfig.js";
export default function Banner() {
const [visible, setVisible] = useState(false);
const storageKey = `ngu-banner-dismissed:${SITE_BANNER.id}`;
useEffect(() => {
if (!SITE_BANNER.enabled) return;
if (!SITE_BANNER.dismissible) return setVisible(true);
try {
if (!localStorage.getItem(storageKey)) setVisible(true);
} catch {
setVisible(true); // storage blocked — show it anyway
}
}, [storageKey]);
const dismiss = () => {
setVisible(false);
try { localStorage.setItem(storageKey, "1"); } catch {}
};
if (!visible) return null;
const linkClass =
"underline underline-offset-4 decoration-[#aac992] hover:text-white transition-colors";
return (
<div
className="relative w-full px-12 py-2.5 text-center"
style={{ background: "rgba(0, 47, 57, 0.95)" }}
role="region"
aria-label="Site announcement"
>
<p className="text-sm text-white/85">
{SITE_BANNER.content.map((part, i) => {
if (!part.href) return <span key={i}>{part.text}</span>;
return part.external ? (
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer" className={linkClass}>
{part.text}
</a>
) : (
<Link key={i} to={part.href} className={linkClass}>
{part.text}
</Link>
);
})}
</p>
{SITE_BANNER.dismissible && (
<button
onClick={dismiss}
aria-label="Dismiss announcement"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
)}
</div>
);
}

176
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,176 @@
import { Link } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import nguLogo from "../assets/NGU_Logo.svg";
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" 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>
);
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
);
const Social_Links = [
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
];
// Give's destination lives in navConfig so the footer and the nav can't drift.
// Only the label differs down here.
const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy");
const Get_In_Touch = [
{ label: "Feedback", to: "/feedback"},
{ label: "Contact Us", to: "/leadership#contact" },
];
const Footer_Links = [
{ label: "Privacy Policy", to: "/privacy" },
{ label: "Terms of Service", to: "/terms" },
];
const footerLinkClass =
"text-white/40 hover:text-[#aac992] text-sm transition-colors";
const pageLinkClass =
"text-white/80 hover:text-[#aac992] text-sm font-semibold transition-colors";
const sectionLinkClass =
"text-white/45 hover:text-[#aac992] text-sm transition-colors";
const headingClass = "text-white/90 text-sm font-semibold mb-4";
export default function Footer() {
return (
<footer className="py-16 px-6" style={{ background: "rgba(0, 47, 58, 0.92)" }}>
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-8 mb-12">
{/* Brand */}
<div className="md:col-span-3 flex justify-center md:justify-start">
<Link to="/">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</Link>
</div>
{/* Site map — mirrors both nav tiers */}
<div className="md:col-span-6 text-center md:text-left">
<nav aria-label="Site map">
<h2 className={headingClass}>Site Map</h2>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-6">
{PAGE_LINKS.map((page) => {
const sections = PAGE_SECTIONS[page.path] ?? [];
return (
<li key={page.path}>
<Link to={page.path} className={pageLinkClass}>
{page.label}
</Link>
{sections.length > 0 && (
<ul className="mt-2 flex flex-col gap-1.5">
{sections.map((section) => (
<li key={section.hash}>
<Link
to={`${page.path}${section.hash}`}
className={sectionLinkClass}
>
{section.label.replace(/^#/, "")}
</Link>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</nav>
{giveAction && (
<a
href={giveAction.to}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-8 px-6 py-2.5 rounded-full text-sm font-bold text-white whitespace-nowrap transition-all duration-200 hover:scale-105"
style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}
>
Support Us
</a>
)}
</div>
{/* Socials + get in touch */}
<div className="md:col-span-3 flex flex-col items-center md:items-start">
<div className="flex gap-4 mb-8">
{Social_Links.map(({ label, href, Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="footer-icons w-10 h-10 rounded-full flex items-center justify-center text-white/70 transition-colors"
style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}
>
{label === "Instagram" ? <InstagramIcon id="ig-footer" /> : <Icon />}
</a>
))}
</div>
<h2 className={headingClass}>Get in Touch</h2>
<ul className="flex flex-col gap-3">
{Get_In_Touch.map((link) => (
<li key={link.label}>
{link.external ? (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={pageLinkClass}
>
{link.label}
</a>
) : (
<Link to={link.to} className={pageLinkClass}>
{link.label}
</Link>
)}
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
<div className="flex gap-6">
{Footer_Links.map((link) => (
<Link key={link.label} to={link.to} className={footerLinkClass}>
{link.label}
</Link>
))}
</div>
</div>
</div>
</footer>
);
}

325
src/components/Layout.tsx Normal file
View file

@ -0,0 +1,325 @@
import { useState, useEffect, useRef } from "react";
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import Banner from "./Banner.jsx";
import nguLogo from "../assets/NGU_Logo.svg";
import Footer from "./Footer.tsx";
/* ── Right-side nav actions ──────────────────────────────── */
type NavActionItem = {
label: string;
to: string;
variant: string;
external?: boolean;
};
const ACTION_VARIANTS: Record<
string,
{ className: string; style: React.CSSProperties }
> = {
ghost: {
className:
"font-semibold border border-white/30 text-white/85 hover:border-white/60 hover:text-white hover:bg-white/10",
style: {},
},
fancy: {
className: "font-bold text-white hover:scale-105",
style: { background: "linear-gradient(135deg, #008fa8, #88b668)" },
},
};
const ACTION_SIZES = {
desktop: "px-5 py-2 text-sm leading-relaxed",
mobile: "px-8 py-3 text-lg",
};
function NavAction({
item,
size = "desktop",
onClick,
}: {
item: NavActionItem;
size?: keyof typeof ACTION_SIZES;
onClick?: () => void;
}) {
const v = ACTION_VARIANTS[item.variant] ?? ACTION_VARIANTS.ghost;
const className = `inline-block whitespace-nowrap rounded-full transition-all duration-200 ${ACTION_SIZES[size]} ${v.className}`;
return item.external ? (
<a
href={item.to}
target="_blank"
rel="noopener noreferrer"
className={className}
style={v.style}
onClick={onClick}
>
{item.label}
</a>
) : (
<Link to={item.to} className={className} style={v.style} onClick={onClick}>
{item.label}
</Link>
);
}
const navActions = NAV_ACTIONS as NavActionItem[];
/* ── Layout ──────────────────────────────────────────────── */
export default function Layout() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(72);
const headerRef = useRef<HTMLElement>(null);
const location = useLocation();
const sections = PAGE_SECTIONS[location.pathname] ?? [];
const activePage = PAGE_LINKS.find((p) => p.path === location.pathname);
// Close the mobile menu whenever the route changes
useEffect(() => {
setMobileMenuOpen(false);
setActiveSection(null);
}, [location.pathname]);
// The header grows and shrinks: the banner can be dismissed and the
// subnav only exists on some pages. Measure it instead of guessing,
// so the spacer below always matches.
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) =>
setHeaderHeight(entry.contentRect.height)
);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Router does not scroll on navigation. Go to top on a new page,
// or to the anchor if the URL carries one (handles deep links too).
useEffect(() => {
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
return;
}
}
window.scrollTo({ top: 0 });
}, [location.pathname, location.hash, location.key]);
// Highlight whichever section is currently in view
useEffect(() => {
if (sections.length === 0) return;
const targets = sections
.map((s) => document.querySelector(s.hash))
.filter(Boolean);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top
)[0];
if (visible) setActiveSection(`#${visible.target.id}`);
},
// Top margin tracks the live header height so the highlight stays
// accurate whether or not the banner is showing.
{ rootMargin: `-${headerHeight + 30}px 0px -60% 0px`, threshold: 0 }
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
}, [location.pathname, headerHeight]);
return (
<div className="min-h-screen overflow-x-hidden">
{/* ── NAV ─────────────────────────────────────────────── */}
<header
ref={headerRef}
className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md"
>
{/* Tier 0 — site-wide announcement */}
<Banner />
{/* Tier 1 — site navigation */}
<nav
className="flex items-center justify-between px-6 py-4"
aria-label="Site"
style={{ background: "rgba(0, 69, 82,0.92)" }}
>
<div className="flex items-center gap-8">
<Link to="/" aria-label="Next Generation of Unity home" className="inline-block">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</Link>
<div className="hidden md:flex items-center gap-6">
{PAGE_LINKS.map((page) => (
<NavLink
key={page.path}
to={page.path}
end={page.path === "/"}
className={({ isActive }) =>
`text-base font-bold pb-1 border-b-2 transition-colors duration-200 ${
isActive
? "text-white border-[#aac992]"
: "text-white/75 hover:text-white border-transparent"
}`
}
>
{page.label}
</NavLink>
))}
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-3">
{navActions.map((item) => (
<NavAction key={item.label} item={item} />
))}
</div>
<button
className="md:hidden text-white p-2"
aria-label="Open menu"
aria-expanded={mobileMenuOpen}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all" />
<div className="w-6 h-0.5 bg-white mb-1.5" />
<div className="w-6 h-0.5 bg-white" />
</button>
</div>
</nav>
{/* Tier 2 — sections of the current page */}
{sections.length > 0 && (
<nav
className="hidden md:flex items-center px-6 py-2"
aria-label={`On this page: ${activePage?.label}`}
style={{
background: "rgba(0, 47, 57, 0.92)",
borderBottom: "1px solid rgba(45,200,224,0.15)",
}}
>
<span className="text-xs text-white/45">{activePage?.label}</span>
{/* Separates the page label from its sections */}
<span
aria-hidden
className="mx-5 h-4 w-px"
style={{ background: "rgba(45,200,224,0.3)" }}
/>
<div className="flex items-center gap-6">
{sections.map((section) => (
<a
key={section.hash}
href={section.hash}
aria-current={activeSection === section.hash ? "location" : undefined}
className={`text-sm transition-colors duration-200 ${
activeSection === section.hash
? "text-[#aac992]"
: "text-white/75 hover:text-[#aac992]"
}`}
>
{section.label}
</a>
))}
</div>
</nav>
)}
</header>
{/* Keeps page content clear of the fixed header */}
<div aria-hidden style={{ height: headerHeight }} />
{/* Page content */}
<main>
<Outlet />
</main>
{/*Site Footer applied to every page*/}
<Footer />
{/* Mobile menu */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center overflow-y-auto py-16"
style={{ background: "rgba(7,61,74,0.97)" }}
>
<button
className="absolute top-5 right-6 text-white text-3xl"
aria-label="Close menu"
onClick={() => setMobileMenuOpen(false)}
>
×
</button>
<div className="w-64">
{PAGE_LINKS.map((page) => {
const pageSections = PAGE_SECTIONS[page.path] ?? [];
const isActive = location.pathname === page.path;
return (
<div key={page.path}>
<NavLink
to={page.path}
end={page.path === "/"}
className={({ isActive }) =>
`block text-2xl font-bold py-3 transition-colors ${
isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
}`
}
>
{page.label}
</NavLink>
{/* Sections nest under the page they belong to */}
{isActive && pageSections.length > 0 && (
<div
className="ml-3 pl-4 mb-2 flex flex-col"
style={{ borderLeft: "1px solid rgba(45,200,224,0.35)" }}
>
{pageSections.map((section) => (
<a
key={section.hash}
href={section.hash}
className="text-white/80 text-lg py-2 hover:text-[#10d48a] transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{section.label}
</a>
))}
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 flex flex-col items-center gap-3">
{navActions.map((item) => (
<NavAction
key={item.label}
item={item}
size="mobile"
onClick={() => setMobileMenuOpen(false)}
/>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,99 @@
/* ═══════════════════════════════════════════════════════════════
PAGE SHELL
Just the chrome that makes pages look alike: a page header, then
sections with a thin accent banner between them, alternating
backgrounds, and a left-aligned heading with a blurb and rule.
Whatever goes inside a section is passed as `content` — the shell
doesn't care what it is.
No <main> and no footer here — Layout provides both, so a page
using this shell drops straight into the router's Outlet.
Usage:
<PageShell
title="Community"
intro="One line under the page title."
sections={[
{
id: "local", // matches the nav hash, #local
title: "Local Chapters",
blurb: "Optional line under the section heading.",
accent: "#138ba0", // heading, banner, rule
background: "#eef9fb",
actions: <SomeButton />, // optional, right of the heading
content: <YourStuff />,
},
]}
/>
═══════════════════════════════════════════════════════════════ */
const TEAL = "#138ba0";
export function Section({ section }) {
const {
id,
title,
blurb,
accent,
background,
// Optional action bar for this section's heading row — a view
// toggle, a filter, a link. Whatever the section needs; the
// shell just gives it a place to sit.
actions,
content,
} = section;
return (
<section
id={id}
className="py-20"
style={{ background, scrollMarginTop: "5rem" }}
>
{/* Heading — left aligned, optional actions on the right */}
<div className="max-w-6xl mx-auto px-6 mb-10">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-4xl md:text-5xl font-800" style={{ color: accent }}>
{title}
</h2>
{blurb && <p className="mt-2 text-[#4a6b72] max-w-xl">{blurb}</p>}
</div>
{actions}
</div>
<div className="mt-6 h-px w-full" style={{ background: accent, opacity: 0.35 }} />
</div>
{content}
</section>
);
}
export default function PageShell({ title, intro, sections }) {
return (
<>
{/* Page header */}
<div className="pt-24 pb-12 px-6" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto">
<h1 className="text-5xl md:text-6xl font-800" style={{ color: TEAL }}>
{title}
</h1>
{intro && <p className="mt-3 text-lg text-[#4a6b72] max-w-2xl">{intro}</p>}
</div>
</div>
{sections.map((section, i) => (
<div key={section.id}>
{/* Thin solid banner between sections */}
<div className="h-2" style={{ background: section.accent }} />
<Section section={section} />
{i === sections.length - 1 && (
<div className="h-2" style={{ background: section.accent }} />
)}
</div>
))}
</>
);
}

297
src/events.js Normal file
View file

@ -0,0 +1,297 @@
/* ═══════════════════════════════════════════════════════════════
EVENT DATA
Plain JS instead of JSON so it imports cleanly in any environment
(Figma included) — same shape, but comments and trailing commas
are allowed.
Per event:
org_logo hosting org's logo: a filename WITH extension in
public/event-logos/, e.g. "ngu-logo-white-bg.png".
null = fall back to DEFAULT_ORG_LOGO.
image event logo / flyer, same filename rule. A name with
no matching file just renders nothing.
instagram handle for the follow button, e.g. "@nextgenunity".
null = no Instagram button on this card.
color card outline / button color. null = the section's
defaultColor.
gradient card background. null = no gradient.
desc_a first paragraph
desc_b second paragraph (pricing, lodging, a note — anything)
status "upcoming" or "past" — grid view splits on this.
links [] when there's nothing to click yet.
Per section:
accent heading, banner, arrows, dots, fallback messages
defaultColor card color for events that don't set their own
defaultView "carousel" or "grid"
═══════════════════════════════════════════════════════════════ */
const eventsData = {
sections: [
{
id: "national",
title: "National Retreats",
blurb: "Our flagship gatherings, open to young adults across the country.",
accent: "#138ba0",
defaultColor: "#138ba0",
defaultView: "carousel",
background: "#eef9fb",
events: [
{
id: "spring-2025",
title: "Spring Retreat 2026",
theme: "Altering Intertia",
date: "March/April 2026",
location: "Unity Village, MO",
org_logo: "ngu-logo-white-bg.svg",
image: "fall-retreat-logo.svg",
instagram: "@nextgenerationunity",
color: "#f1c2fe",
gradient: "linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
desc_a: "A weekend of connection, workshops, and community for young adults across the Unity movement.",
desc_b: null,
status: "past",
links: []
},
{
id: "fall-retreat-2026",
title: "Fall Retreat 2026",
theme: "Consciousness Creates",
date: "November 12-15th, 2026",
location: "Unity Village, MO",
org_logo: "ngu-logo-white-bg.svg",
image: "fall-retreat-logo.svg",
instagram: "@nextgenerationunity",
color: "#b89421",
gradient: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
desc_a: "Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
desc_b: "Registration starting at $150, and $75 lodging cost.",
status: "upcoming",
links: [
{
label: "Register Now!",
link: "https://ngu.churchcenter.com/registrations/events/3761999"
},
{
label: "Scholarship Application",
link: "https://ngu.churchcenter.com/people/forms/1261992"
},
{
label: "Volunteer",
link: "https://ngu.churchcenter.com/people/forms/1176908"
}
]
},
{
id: "spring-recharge-2027",
title: "Spring Recharge 2027",
theme: "TBD",
date: "March 6th, 2027",
location: "Online",
org_logo: "ngu-logo-white-bg.svg",
image: null,
instagram: "@nextgenerationunity",
color: "#138ba0",
gradient: null,
desc_a: "One-day online event to reconnect in the spring.",
desc_b: null,
status: "upcoming",
links: []
},
{
id: "spring-service-2027",
title: "Service Week 2027",
theme: "Leadership & Service",
date: "April 4-9th, 2027",
location: "Unity Village, MO",
org_logo: "ngu-logo-white-bg.svg",
image: null,
instagram: "@nextgenerationunity",
color: "#138ba0",
gradient: null,
desc_a: "Join us at beautiful Unity Village for a week of leadership development and service projects.",
desc_b: null,
status: "upcoming",
links: []
}
]
},
{
id: "regional",
title: "Regional Retreats",
blurb: "Smaller gatherings hosted by regions throughout the year.",
accent: "#aac992",
defaultColor: "#aac992",
defaultView: "grid",
background: "#ffffff",
events: [
{
id: "northwest-2026",
title: "Northwest Regional 2026",
theme: "Theme name",
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: "@nw.ngu",
color: null,
gradient: null,
desc_a: "Short description of the regional retreat goes here.",
desc_b: null,
status: "past",
links: []
},
{
id: "northwest-2027",
title: "Northwest Regional 2027",
theme: "Theme name",
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: "@nw.ngu",
color: null,
gradient: null,
desc_a: "Short description of the regional retreat goes here.",
desc_b: null,
status: "upcoming",
links: []
},
{
id: "northwest-2028",
title: "Northwest Regional 2028",
theme: "Theme name",
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: "@nw.ngu",
color: null,
gradient: null,
desc_a: "Short description of the regional retreat goes here.",
desc_b: null,
status: "upcoming",
links: []
}
]
},
{
id: "partner",
title: "Partner Events",
blurb: "Events hosted by organizations we collaborate with.",
accent: "#7a5ea8",
defaultColor: "#7a5ea8",
defaultView: "grid",
background: "#eef9fb",
events: [
{
id: "partner-example",
title: "Partner Event Name",
theme: null,
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: null,
color: null,
gradient: null,
desc_a: "Short description of the partner event goes here.",
desc_b: "Hosted by Partner Organization.",
status: "past",
links: [
{
label: "Learn More",
link: "partner-link"
}
]
},
{
id: "partner-example",
title: "Partner Event Name",
theme: null,
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: null,
color: null,
gradient: null,
desc_a: "Short description of the partner event goes here.",
desc_b: "Hosted by Partner Organization.",
status: "past",
links: [
{
label: "Learn More",
link: "partner-link"
}
]
},
{
id: "partner-example",
title: "Partner Event Name",
theme: null,
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: null,
color: null,
gradient: null,
desc_a: "Short description of the partner event goes here.",
desc_b: "Hosted by Partner Organization.",
status: "upcoming",
links: [
{
label: "Learn More",
link: "partner-link"
}
]
},
{
id: "partner-example",
title: "Partner Event Name",
theme: null,
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: null,
color: null,
gradient: null,
desc_a: "Short description of the partner event goes here.",
desc_b: "Hosted by Partner Organization.",
status: "upcoming",
links: [
{
label: "Learn More",
link: "partner-link"
}
]
},
{
id: "partner-example",
title: "Partner Event Name",
theme: null,
date: "Date",
location: "Location",
org_logo: null,
image: null,
instagram: null,
color: null,
gradient: null,
desc_a: "Short description of the partner event goes here.",
desc_b: "Hosted by Partner Organization.",
status: "upcoming",
links: [
{
label: "Learn More",
link: "partner-link"
}
]
}
]
}
]
};
export default eventsData;

52
src/navConfig.js Normal file
View file

@ -0,0 +1,52 @@
// Single source of truth for navigation.
// Add a page here and in App.jsx, and both tiers of the nav update.
export const PAGE_LINKS = [
{ label: "Home", path: "/" },
{ label: "Retreats", path: "/retreats" },
{ label: "Community", path: "/community" },
{ label: "Leadership", path: "/leadership" },
{ label: "Resources", path: "/resources" },
];
// Sections belong to a page, keyed by that page's path.
// A page with no sections gets no subnav row.
export const PAGE_SECTIONS = {
"/": [
{ label: "#About", hash: "#about" },
{ label: "#Events", hash: "#events" },
{ label: "#Connect", hash: "#connect" },
],
"/retreats": [
{ label: "National", hash: "#national" },
{ label: "Regional", hash: "#regional" },
{ label: "Partner Events", hash: "#partner"},
],
"/community": [
{ label: "Local Chapters", hash: "#local" },
{ label: "Virtual Community", hash: "#virtual" },
{ label: "Partner Organizations", hash: "#partners" },
],
"/leadership": [
{ label: "Board & Staff", hash: "#board-staff" },
{ label: "Retreat Team", hash: "#retreat-team" },
{ label: "Committees & Volunteers", hash: "#committees" },
{ label: "Contact Us", hash: "#contact" },
],
"/resources": [
{ label: "Unity Teachings", hash: "#unity" },
{ label: "Branding & Marketing", hash: "#branding" },
{ label: "Partner Resources", hash: "#partner" },
],
};
// Right-side actions. `variant` picks the styling, not the destination.
export const NAV_ACTIONS = [
{ label: "Contact Us", to: "/leadership#contact", variant: "ghost" },
{
label: "Give",
to: "/give",
variant: "fancy",
external: true,
},
];

717
src/pages/Chapters.tsx Normal file
View file

@ -0,0 +1,717 @@
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>
);
}

64
src/pages/Community.tsx Normal file
View file

@ -0,0 +1,64 @@
import { useState } from "react";
import PageShell from "../components/PageShell.jsx";
import LocalChapters, { ChaptersViewToggle } from "./Chapters.jsx";
/* 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";
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>
),
},
];
return (
<PageShell
title="Community"
intro="Ways to connect with Next Generation of Unity, in your area, online, and through the organizations we work alongside."
sections={sections}
/>
);
}

307
src/pages/Feedback.tsx Normal file
View file

@ -0,0 +1,307 @@
import { useState } from "react";
import PageShell from "../components/PageShell.jsx";
/* ═══════════════════════════════════════════════════════════════
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.
Nothing is persisted. handleSubmit just flips to the thank-you
panel; the POST goes where the comment marks it.
═══════════════════════════════════════════════════════════════ */
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",
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: (
<div className="mx-auto max-w-3xl px-6">
<WebsiteFeedbackForm />
</div>
),
},
];
export default function FeedbackPage() {
return (
<PageShell
title="Feedback"
intro="Tell us what's working, what isn't, and what you'd like to see next."
sections={SECTIONS}
/>
);
}

89
src/pages/Giving.tsx Normal file
View file

@ -0,0 +1,89 @@
import { useEffect } from "react";
import PageShell from "../components/PageShell.tsx";
const GIVING_URL =
"https://ngu.churchcenteronline.com/giving?open-in-church-center-modal=true";
const MODAL_SCRIPT_SRC = "https://js.churchcenter.com/modal/v1";
const ACCENT = "#138ba0";
function useChurchCenterModal() {
useEffect(() => {
if (document.querySelector(`script[src="${MODAL_SCRIPT_SRC}"]`)) return;
const script = document.createElement("script");
script.src = MODAL_SCRIPT_SRC;
script.async = true;
document.head.appendChild(script);
// Deliberately not removed on unmount: the script registers a
// document-level click handler, and tearing it down on every
// route change would mean re-fetching it on the way back.
}, []);
}
/* ═══════════════════════════════════════════════════════════════
GIVING SECTION
═══════════════════════════════════════════════════════════════ */
function GivingPanel() {
return (
<div className="max-w-2xl mx-auto px-6 text-center">
<p className="text-slate-700 leading-relaxed">
Gifts are processed securely through Church Center. The form opens
right here on the page and you can give once or set up a recurring
gift, and you don&rsquo;t need an account to do either.
</p>
<a
href={GIVING_URL}
className="
mt-8 inline-flex items-center justify-center
rounded-full px-10 py-4
text-base font-semibold tracking-wide text-white
shadow-sm transition
hover:brightness-110 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-offset-2
motion-reduce:transition-none
"
style={{ backgroundColor: ACCENT, "--tw-ring-color": ACCENT } as React.CSSProperties}
>
Give now
</a>
<p className="mt-4 text-sm text-slate-500">
If you would like to send a check, please email our Board Treasurer at treasurer@nextgenerationofunity.org to get an address.
</p>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
export default function GivingPage() {
useChurchCenterModal();
const sections = [
{
id: "give",
title: "Donate to NGU",
blurb:
"Your giving funds scholarships, retreats, leadership development and the continued growth of the Next Generation of Unity leaders, teachers and community members.",
accent: ACCENT,
background: "#eef9fb",
content: <GivingPanel />,
},
];
return (
<PageShell
title="Giving"
intro="Support the work of Next Generation of Unity."
sections={sections}
/>
);
}

477
src/pages/Home.tsx Normal file
View file

@ -0,0 +1,477 @@
import { useState, useEffect } from "react";
import nguLogo from "../assets/NGU_Logo.svg";
import fallLogo from "../assets/Fall Logo.svg";
import nguLogo_WhiteBG from "../assets/NGU_Logo_WhiteBG.svg";
{/* SVGs */}
const DoveSVG = ({ className = "" }: { className?: string }) => (
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
<defs id="defs1" />
<g id="layer1" transform="translate(-70.490069,-117.83965)">
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
</g>
</g>
</svg>
);
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" 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>
);
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
);
{/* Link Tables */}
const Social_Links = [
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
]
const Footer_Links = [
{ label: "Privacy Policy", href: "#"},
{ label: "Terms of Service", href: "#"},
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
]
const EVENTS = [
{
id: "spring-2025",
title: "Spring Retreat 2026",
theme: "Altering Intertia",
date: "March/April 2026",
location: "Unity Village, MO",
image: fallLogo, // e.g. springLogo
color: "#f1c2fe",
gradient:
"linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
desc_a:
"A weekend of connection, workshops, and community for young adults across the Unity movement.",
desc_b: null,
status: "past",
links: [
],
},
{
id: "fall-retreat-2026",
title: "Fall Retreat 2026",
theme: "Consciousness Creates",
date: "November 12-15th, 2026",
location: "Unity Village, MO",
image: fallLogo,
color: "#b89421",
gradient:
"linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
desc_a:
"Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
desc_b: "Registration starting at $150, and $75 lodging cost.",
status: "upcoming",
links: [
{ label: "Register Now!", link: "https://ngu.churchcenter.com/registrations/events/3761999" },
{ label: "Scholarship Application", link: "https://ngu.churchcenter.com/people/forms/1261992" },
{ label: "Volunteer", link: "https://ngu.churchcenter.com/people/forms/1176908" },
],
},
{
id: "spring-recharge-2027",
title: "Spring Recharge 2027",
theme: "TBD",
date: "March 6th, 2027",
location: "Online",
image: null, // e.g. eventLogo
color: "#138ba0",
gradient:
null,
desc_a:
"One-day online event to reconnect in the spring.",
desc_b: null,
status: "upcoming",
links: [
],
},
{
id: "spring-service-2027",
title: "Service Week 2027",
theme: "Leadership & Service",
date: "April 4-9th, 2027",
location: "Unity Village, MO",
image: null, // e.g. eventLogo
color: "#138ba0",
gradient:
null,
desc_a:
"Join us at beautiful Unity Village for a week of leadership development and service projects.",
desc_b: null,
status: "upcoming",
links: [
],
}
];
const START_INDEX = (() => {
const i = EVENTS.findIndex(e => e.status === "upcoming");
return i === -1 ? EVENTS.length - 1 : i;
})();
const TEAL = "#138ba0";
const CARD = "min(48rem, 90vw)"; // the card itself — your original max-w-3xl
const GAP = "5rem"; // space between cards ← this is your knob
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})))`;
{/* Functions */}
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
return (
<>
{text.split("").map((char, i) => (
<span
key={i}
className="float-anim"
style={{ animationDelay: `${-i * step}s` }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</>
);
}
export default function Home() {
{/* Other useState consts and Functions*/}
const [showCalendar, setShowCalendar] = useState(false);
const [index, setIndex] = useState(START_INDEX);
const event = EVENTS[index];
const isPast = event.status === "past";
const currentColor = event.color || TEAL;
const prev = () => setIndex(i => Math.max(0, i - 1));
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
const arrowStyle = enabled => ({
border: `1px solid ${TEAL}`,
background: "rgba(255,255,255,0.6)",
color: enabled ? TEAL : "#b8c6c9",
cursor: enabled ? "pointer" : "default",
opacity: enabled ? 1 : 0.4,
});
{/* Start of Main Content*/}
return (
<>
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
<section id="hero" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
{/* Floating doves */}
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
<div className="relative z-10 max-w-4xl mx-auto px-6">
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
Next Generation<br />
<span className="grad-hero-text">
<WaveText text="of Unity" step={0.1} />
</span>
</h1>
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
</p>
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
We are the future of the Unity Movement.
</p>
<div className="flex flex-wrap gap-4 justify-center">
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
Follow Us on Instagram
</a>
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
Attend a Retreat
</a>
</div>
</div>
</section>
{/* #About ─────────────────────────────────────── */}
<section id="about" className="py-24 px-6" style={{ background: "#f0fcfd" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
].map((card) => (
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
<div className="text-4xl mb-4">{card.icon}</div>
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* #Events ─────────────────────────────────────────────── */}
<section id="events" className="py-24 overflow-hidden" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
{isPast ? "Past Events" : "Upcoming Events"}
</h2>
</div>
</div>
{/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
<div className="relative mb-6">
{/* Masked viewport: everything outside the fade gradient is invisible */}
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
{/* Sliding track */}
<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;
const past = ev.status === "past";
const color = ev.color || TEAL;
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}
>
<div
className="rounded-3xl text-black overflow-hidden shadow-2xl h-full"
style={{
border: `1px solid ${color}`,
background: ev.gradient,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: active ? "auto" : "none",
}}
>
<div className="p-10">
<div className="grid grid-cols-1 md:grid-cols-3 mb-4">
<div className="md:col-span-2">
<img
src={nguLogo_WhiteBG}
alt="Next Generation of Unity"
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>
)}
<p className="text-2xl">{ev.date}</p>
<p className="text-2xl">{ev.location}</p>
</div>
<div className="md:col-span-1 flex justify-end items-start">
{ev.image && (
<img src={ev.image} alt={ev.title} className="h-60" />
)}
</div>
</div>
{ev.desc_a && <p className="mb-2 leading-relaxed">{ev.desc_a}</p>}
{ev.desc_b && <p className="leading-relaxed">{ev.desc_b}</p>}
{ev.links.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-8">
{ev.links.map(item => (
<a
key={item.label}
href={item.link}
className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center"
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
</div>
) : (
past ? (
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
This event has concluded, thank you to everyone who joined us!
</p>
) : (
<>
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
Registration has not opened yet, follow our instagram for more details.
</p>
<a href="https://instagram.com/nextgenerationunity" target="_blank" rel="noopener noreferrer" className="ig-link mt-4 w-fit mx-auto flex items-center justify-center gap-3 py-3 px-4 rounded-xl font-700 transition-all duration-200 hover:scale-[1.02]" style={{ border: `1px solid ${color}`, color }}>
<InstagramIcon id={`ig-${ev.id}`} />
@nextgenerationunity
</a>
</>
)
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Arrows — outside the masked element so they never fade */}
<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>
<div className="max-w-6xl mx-auto px-6">
{/* Dot indicators */}
<div className="flex justify-center gap-2 mb-10">
{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 ? currentColor : "#b8c6c9",
}}
/>
))}
</div>
<p className="text-center text-[#138ba0] font-600 text-sm">
· More events coming soon, stay connected for announcements ·
</p>
</div>
</section>
{/* #Connect -------------------------------------------- */}
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
Ready to Connect?
</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{[
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
].map(item => (
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
<div>
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
</div>
{/* Arrow */}
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
</svg>
</a>
))}
</div>
<div className="mt-10 text-center">
<div className="inline-flex flex-wrap justify-center gap-4">
<a
href="https://ngu.churchcenter.com/calendar?view=gallery"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105"
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}
>
📅 View NGU Calendar
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button
onClick={() => setShowCalendar(!showCalendar)}
className="inline-flex items-center gap-2 px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105"
style={{ border: "2px solid #138ba0", color: "#138ba0", background: "transparent", fontFamily: "Outfit,sans-serif" }}
>
{showCalendar ? "▲ Hide Calendar" : "▼ Show Calendar Here"}
</button>
</div>
{showCalendar && (
<div className="mt-8 mx-auto max-w-4xl rounded-2xl shadow-lg p-6" style={{ border: "2px solid #138ba0" }}>
<iframe
src="https://ngu.churchcenter.com/calendar?embed=true&view=month"
title="NGU Calendar"
className="w-full planning-center-calender-embed"
style={{ height: "700px", border: "none" }}
/>
</div>
)}
</div>
</div>
</section>
</>
);
}

68
src/pages/Leadership.tsx Normal file
View file

@ -0,0 +1,68 @@
import PageShell from "../components/PageShell.jsx";
const SECTIONS = [
{
id: "board-staff",
title: "Board & Staff",
blurb: "The people guiding Next Generation of Unity day to day.",
accent: "#138ba0",
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Board & staff content */}
</div>
),
},
{
id: "retreat-team",
title: "Retreat Team",
blurb: "Volunteers who plan and lead our gatherings throughout the year.",
accent: "#aac992",
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Retreat team content */}
</div>
),
},
{
id: "committees",
title: "Committees & Volunteers",
blurb: "Additional roles and contributors.",
accent: "#7a5ea8",
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Other roles content */}
</div>
),
},
{
id: "contact",
title: "Contact Us",
blurb: "Reach out to our staff",
accent: "#004c52",
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
<a
href="mailto:info@nextgenerationofunity.org"
className="inline-block py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
style={{ border: "1px solid #138ba0", color: "#138ba0" }}
>
info@nextgenerationofunity.org
</a>
</div>
),
},
];
export default function Leadership() {
return (
<PageShell
title="Leadership"
intro="The board, staff, and volunteers who keep Next Generation of Unity running."
sections={SECTIONS}
/>
);
}

16
src/pages/NotFound.tsx Normal file
View file

@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
export default function NotFound() {
return (
<section className="px-6 py-20">
<h1 className="text-4xl font-bold">That page isn't here</h1>
<p className="mt-4 text-lg">
The link may be out of date.{" "}
<Link to="/" className="underline">
Go to the home page
</Link>
.
</p>
</section>
);
}

13
src/pages/Other.tsx Normal file
View file

@ -0,0 +1,13 @@
export default function Other() {
return (
<>
<section id="sectiona" className="scroll-mt-28 px-6 py-20">
<h1 className="text-4xl font-bold">Section A</h1>
</section>
<section id="sectionb" className="scroll-mt-28 px-6 py-20">
<h2 className="text-4xl font-bold">Section B</h2>
</section>
</>
);
}

26
src/pages/Privacy.tsx Normal file
View file

@ -0,0 +1,26 @@
import PageShell from "../components/PageShell.jsx";
const SECTIONS = [
{
id: "privacy",
title: "Privacy Policy",
blurb: "Last updated: [date]",
accent: "#138ba0",
background: "#eef9fb",
content: (
<div className="max-w-3xl mx-auto px-6 leading-relaxed text-[#2c4a50]">
{/* Policy text goes here */}
</div>
),
},
];
export default function PrivacyPage() {
return (
<PageShell
title="Privacy Policy"
intro="How Next Generation of Unity collects, uses, and protects your information."
sections={SECTIONS}
/>
);
}

50
src/pages/Resources.tsx Normal file
View file

@ -0,0 +1,50 @@
import PageShell from "../components/PageShell.jsx";
const SECTIONS = [
{
id: "unity",
title: "Unity Teachings",
blurb: "Core principles, readings, and study material from the Unity movement.",
accent: "#138ba0",
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Unity teachings content */}
</div>
),
},
{
id: "branding",
title: "Branding & Marketing",
blurb: "Logos, colors, templates, and guidelines for representing NGU.",
accent: "#aac992",
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Branding & marketing content */}
</div>
),
},
{
id: "partner",
title: "Partner Resources",
blurb: "Material for the organizations we collaborate with.",
accent: "#7a5ea8",
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Partner resources content */}
</div>
),
},
];
export default function ResourcesPage() {
return (
<PageShell
title="Resources"
intro="Teachings, brand assets, and material for chapters and partners."
sections={SECTIONS}
/>
);
}

590
src/pages/Retreats.tsx Normal file
View file

@ -0,0 +1,590 @@
import { useState } from "react";
import PageShell from "../components/PageShell.tsx"
import eventsData from "../events.js";
const LOGO_FILES = import.meta.glob("../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
const logoSrc = file => (file && LOGOS[file]) || null;
// Used when an event doesn't name its own org_logo.
const DEFAULT_ORG_LOGO = null;
/* An <img> that removes itself if the file 404s. */
function Logo({ file, alt = "", className }) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
return (
<img
src={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
/>
);
}
const TEAL = "#138ba0"; // page heading + last-resort card color
/* ── Grid view ────────────────────────────────────────────────
The grid fits as many columns as the screen allows, with no
breakpoints: each card is at least CARD_MIN wide, and the
columns share whatever space is left over.
CARD_MIN narrowest a card may get before dropping a column
MAX_COLS ceiling on columns, so cards don't get absurd on
very wide monitors. GRID_MAX is derived from it.
───────────────────────────────────────────────────────────── */
const CARD_MIN = "32rem";
const MAX_COLS = 4;
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
/* Collapsed past-events strip: how much shows, and the downward
fade applied while it's collapsed. */
const PAST_PEEK = "10rem";
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
/* ── Carousel sizing ──────────────────────────────────────────
CARD the card itself (your original max-w-3xl)
GAP space between cards — raise to push neighbors out
FADE_DIST how far past the card's edge neighbors fade to nothing
───────────────────────────────────────────────────────────── */
const CARD = "min(48rem, 90vw)";
const GAP = "5rem";
const SLIDE = `calc(${CARD} + ${GAP})`;
const HALF_SLIDE = `calc(${CARD} / 2)`;
const FADE_DIST = "18rem";
const EDGE_FADE = `linear-gradient(to right,
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
black calc(50% - ${HALF_SLIDE}),
black calc(50% + ${HALF_SLIDE}),
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
/* ── Grid-card layout ─────────────────────────────────────────
Container queries, not media queries: each card reacts to its
OWN width, which is what we need now that the column count is
dynamic and the viewport says nothing about how wide a card is.
Wide card NGU logo + details on the left, event logo on the
right, descriptions full-width underneath.
Narrow card logos on their own row, then details, then
descriptions — three stacked rows.
───────────────────────────────────────────────────────────── */
const CARD_STYLES = `
.ev-card { container-type: inline-size; }
.ev-grid {
display: grid;
gap: 0.25rem 1.5rem;
grid-template-columns: 2fr 1fr;
grid-template-areas:
"ngu image"
"info image"
"desc desc";
}
@container (max-width: 30rem) {
.ev-grid {
grid-template-columns: 1fr;
grid-template-areas:
"ngu"
"info"
"desc";
}
.ev-grid .ev-image {
justify-self: start;
width: auto;
max-height: 7rem;
margin-bottom: 0.5rem;
}
}
.ev-ngu { grid-area: ngu; }
.ev-info { grid-area: info; }
.ev-image {
grid-area: image;
justify-self: stretch;
align-self: center;
max-height: 11rem;
}
.ev-desc { grid-area: desc; margin-top: 1rem; }
`;
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg
viewBox="0 0 24 24"
className="ig-icon w-6 h-6"
style={{ "--ig-fill": `url(#${id})` }}
>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<stop offset="75%" stopColor="#962FBF" />
<stop offset="100%" stopColor="#4F5BD5" />
</linearGradient>
</defs>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel
compact=true → the grid card: details left, text right, and a
footer pinned to the bottom so every card in a
row is the same height with aligned buttons.
═══════════════════════════════════════════════════════════════ */
export function Card({
ev,
defaultColor = TEAL,
accent = TEAL,
compact = false,
interactive = true,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
const eventLogo = ev.image;
const igHandle = ev.instagram || null;
const igUrl = igHandle
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null;
const descriptions = (
<>
{ev.desc_a && (
<p className={`mb-2 leading-relaxed ${compact ? "text-base" : ""}`}>
{ev.desc_a}
</p>
)}
{ev.desc_b && (
<p className={`leading-relaxed ${compact ? "text-base" : ""}`}>
{ev.desc_b}
</p>
)}
</>
);
return (
<div
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
compact ? "ev-card" : ""
}`}
style={{
border: `1px solid ${color}`,
background: ev.gradient || undefined,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: interactive ? "auto" : "none",
}}
>
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
{compact ? (
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
{ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date && <p className="text-xl">{ev.date}</p>}
{ev.location && <p className="text-xl">{ev.location}</p>}
</div>
<Logo
file={eventLogo}
alt={ev.title}
className="ev-image w-full h-auto object-contain"
/>
<div className="ev-desc">{descriptions}</div>
</div>
) : (
/* ── FULL: details left, large event logo right ── */
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
<h3 className="text-4xl font-900">{ev.title}</h3>
{ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date && <p className="text-2xl">{ev.date}</p>}
{ev.location && <p className="text-2xl">{ev.location}</p>}
</div>
<div className="md:col-span-1 flex items-center justify-end">
<Logo
file={eventLogo}
alt={ev.title}
className="w-full h-auto max-h-72 object-contain"
/>
</div>
</div>
{descriptions}
</>
)}
{/* Footer — mt-auto pins it to the bottom so buttons line up
across every card in a grid row. */}
{ev.links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
{ev.links.map(item => (
<a
key={item.label}
href={item.link}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
</div>
) : past ? (
<p
className={`mt-auto text-center font-600 ${compact ? "pt-8" : "pt-8"}`}
style={{ color: accent }}
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
<a
href={igUrl}
target="_blank"
rel="noopener noreferrer"
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
}`}
style={{ border: `1px solid ${accent}`, color: accent }}
>
<InstagramIcon id={`ig-${ev.id}`} />
{igHandle}
</a>
)}
</div>
)}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
VIEW TOGGLE — carousel / grid segmented control
═══════════════════════════════════════════════════════════════ */
function ViewToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
return (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how events are displayed"
>
<button
onClick={() => setView("carousel")}
aria-pressed={view === "carousel"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "carousel")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="7" y="5" width="10" height="14" rx="2" />
<path d="M3.5 8v8M20.5 8v8" />
</svg>
Carousel
</button>
<button
onClick={() => setView("grid")}
aria-pressed={view === "grid"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "grid")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
Grid
</button>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION — heading + toggle, then carousel or grid
═══════════════════════════════════════════════════════════════ */
export function CardSection({ section, view }) {
const {
events,
accent,
defaultColor,
} = section;
const startIndex = (() => {
const i = events.findIndex(e => e.status === "upcoming");
return i === -1 ? Math.max(0, events.length - 1) : i;
})();
const [index, setIndex] = useState(startIndex);
const [showPast, setShowPast] = useState(false);
// Grid view splits the list; the carousel still shows everything.
const upcoming = events.filter(e => e.status !== "past");
const pastEvents = events.filter(e => e.status === "past");
const prev = () => setIndex(i => Math.max(0, i - 1));
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
// Arrows and dots follow the section accent, not the active card.
const arrowStyle = enabled => ({
border: `1px solid ${accent}`,
background: "rgba(255,255,255,0.85)",
color: enabled ? accent : "#b8c6c9",
cursor: enabled ? "pointer" : "default",
opacity: enabled ? 1 : 0.4,
});
return (
<div className="overflow-hidden">
{events.length === 0 ? (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
· Events coming soon, stay connected for announcements ·
</p>
) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
{upcoming.length > 0 && (
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{upcoming.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={defaultColor}
accent={accent}
compact
/>
))}
</div>
)}
{pastEvents.length > 0 && (
<div className="mt-12">
<h3
className="text-2xl font-800 mb-6"
style={{ color: accent, opacity: 0.85 }}
>
Past Events
</h3>
{/* Collapsed: clipped to PAST_PEEK and faded out at the
bottom. Expanded: full height, no mask. */}
<div
className="relative transition-all duration-500 ease-out"
style={{
maxHeight: showPast ? "none" : PAST_PEEK,
overflow: showPast ? "visible" : "hidden",
maskImage: showPast ? "none" : PAST_FADE,
WebkitMaskImage: showPast ? "none" : PAST_FADE,
}}
>
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
aria-hidden={!showPast}
>
{pastEvents.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={defaultColor}
accent={accent}
compact
interactive={showPast}
/>
))}
</div>
</div>
<div className="flex justify-center mt-6">
<button
onClick={() => setShowPast(v => !v)}
aria-expanded={showPast}
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{showPast
? "Hide past events"
: `See past events (${pastEvents.length})`}
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform duration-300"
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
/* ── CAROUSEL VIEW ── */
<>
<div className="relative mb-6">
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
<div
className="flex items-stretch transition-transform duration-500 ease-out"
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
>
{events.map((ev, i) => {
const active = i === index;
return (
<div
key={ev.id}
className="shrink-0"
style={{
width: SLIDE,
padding: `0 calc(${GAP} / 2)`,
cursor: active ? "default" : "pointer",
}}
onClick={() => !active && setIndex(i)}
aria-hidden={!active}
>
<Card
ev={ev}
defaultColor={defaultColor}
accent={accent}
interactive={active}
/>
</div>
);
})}
</div>
</div>
{events.length > 1 && (
<>
<button
onClick={prev}
disabled={index === 0}
aria-label="Previous event"
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index > 0)}
>
‹
</button>
<button
onClick={next}
disabled={index === events.length - 1}
aria-label="Next event"
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index < events.length - 1)}
>
›
</button>
</>
)}
</div>
{events.length > 1 && (
<div className="flex justify-center gap-2">
{events.map((e, i) => (
<button
key={e.id}
onClick={() => setIndex(i)}
aria-label={`Go to ${e.title}`}
className="h-2.5 rounded-full transition-all duration-200"
style={{
width: i === index ? "1.5rem" : "0.625rem",
background: i === index ? accent : "#b8c6c9",
}}
/>
))}
</div>
)}
</>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
RETREATS PAGE — hands the card sections to the shared PageShell
so this page matches Community, Leadership and Resources.
View state lives here, one entry per section, so the toggle in a
section's action bar and the cards below it stay in sync.
═══════════════════════════════════════════════════════════════ */
export default function RetreatsPage() {
const [views, setViews] = useState(() =>
Object.fromEntries(
eventsData.sections.map(s => [s.id, s.defaultView || "carousel"])
)
);
const setView = (id, value) =>
setViews(prev => ({ ...prev, [id]: value }));
const shellSections = eventsData.sections.map(section => {
const view = views[section.id];
return {
id: section.id,
title: section.title,
blurb: section.blurb,
accent: section.accent,
background: section.background,
actions: section.events.length > 0 && (
<ViewToggle
view={view}
setView={value => setView(section.id, value)}
accent={section.accent}
/>
),
content: <CardSection section={section} view={view} />,
};
});
return (
<>
<style>{CARD_STYLES}</style>
<PageShell
title="Retreats"
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
sections={shellSections}
/>
</>
);
}

26
src/pages/Terms.tsx Normal file
View file

@ -0,0 +1,26 @@
import PageShell from "../components/PageShell.jsx";
const SECTIONS = [
{
id: "terms",
title: "Terms of Service",
blurb: "Last updated: [date]",
accent: "#138ba0",
background: "#eef9fb",
content: (
<div className="max-w-3xl mx-auto px-6 leading-relaxed text-[#2c4a50]">
{/* Terms text goes here */}
</div>
),
},
];
export default function TermsPage() {
return (
<PageShell
title="Terms of Service"
intro="The terms that apply when you use this site and our programs."
sections={SECTIONS}
/>
);
}

353
src/pages/chapters.js Normal file
View file

@ -0,0 +1,353 @@
/* ═══════════════════════════════════════════════════════════════
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");