v1.2 - added react router
|
|
@ -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
736
src/App.tsx
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
1
src/assets/event-logos/fall-retreat-logo.svg
Normal file
|
After Width: | Height: | Size: 221 KiB |
82
src/assets/event-logos/ngu-logo-white-bg.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/PageShell.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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’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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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");
|
||||||