diff --git a/index.html b/index.html
index d51965e..2eedec2 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,7 @@
-
- {/* ── NAV ─────────────────────────────────────────────── */}
-
-
- {/* Tier 1 — site navigation */}
-
-
-
-
-
-
-
- {PAGE_LINKS.map((page) => (
- 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}
-
- ))}
-
-
-
-
-
- Give
-
-
-
setMobileMenuOpen(!mobileMenuOpen)}
- >
-
-
-
-
-
-
-
- {/* Tier 2 — sections of the current page */}
- {sections.length > 0 && (
-
- {/* Anchors the row to its parent page */}
-
- {activePage?.label}
-
-
- {sections.map((section) => (
-
- {section.label}
-
- ))}
-
- )}
-
-
- {/* Keeps page content clear of the fixed header */}
-
0 ? "h-[6.75rem]" : "h-[4.5rem]"} />
-
- {/* Mobile menu */}
- {mobileMenuOpen && (
-
-
setMobileMenuOpen(false)}
- >
- ×
-
-
-
- {PAGE_LINKS.map((page) => {
- const pageSections = PAGE_SECTIONS[page.tab] ?? [];
- const isActive = activeTab === page.tab;
-
- return (
-
-
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}
-
-
- {/* Sections nest under the page they belong to */}
- {isActive && pageSections.length > 0 && (
-
- )}
-
- );
- })}
-
-
-
setMobileMenuOpen(false)}
- >
- Give
-
-
- )}
-
- {/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
-
- {/* Floating doves */}
-
-
-
-
-
-
- Next Generation
-
-
-
-
-
-
- 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.
-
-
- We are the future of the Unity Movement.
-
-
-
-
-
-
- {/* #About ─────────────────────────────────────── */}
-
-
-
-
A Ministry Designed For Young Adults
-
- NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
-
-
-
-
- {[
- { 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) => (
-
-
{card.icon}
-
{card.title}
-
{card.desc}
-
- ))}
-
-
-
- {/* #Events ─────────────────────────────────────────────── */}
-
-
-
-
- {isPast ? "Past Events" : "Upcoming Events"}
-
-
-
-
- {/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
-
- {/* Masked viewport: everything outside the fade gradient is invisible */}
-
- {/* Sliding track */}
-
- {EVENTS.map((ev, i) => {
- const active = i === index;
- const past = ev.status === "past";
- const color = ev.color || TEAL;
- return (
-
!active && setIndex(i)}
- aria-hidden={!active}
- >
-
-
-
-
-
-
{ev.title}
- {ev.theme && (
-
"{ev.theme}"
- )}
-
{ev.date}
-
{ev.location}
-
-
- {ev.image && (
-
- )}
-
-
-
- {ev.desc_a &&
{ev.desc_a}
}
- {ev.desc_b &&
{ev.desc_b}
}
-
- {ev.links.length > 0 ? (
-
- ) : (
- past ? (
-
- This event has concluded, thank you to everyone who joined us!
-
- ) : (
- <>
-
- Registration has not opened yet, follow our instagram for more details.
-
-
-
- @nextgenerationunity
-
- >
- )
- )}
-
-
-
- );
- })}
-
-
-
- {/* Arrows — outside the masked element so they never fade */}
-
0)}
- >
- ‹
-
-
- ›
-
-
-
-
- {/* Dot indicators */}
-
- {EVENTS.map((e, i) => (
- 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",
- }}
- />
- ))}
-
-
-
- · More events coming soon, stay connected for announcements ·
-
-
-
- {/* #Connect -------------------------------------------- */}
-
-
-
-
- Ready to Connect?
-
-
Find your place in the NGU community
-
-
-
- {[
- { 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 => (
-
-
-
{item.label}
-
{item.desc}
-
- {/* Arrow */}
-
-
-
-
- ))}
-
-
-
-
-
- 📅 View NGU Calendar
-
-
-
-
-
-
-
-
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"}
-
-
-
- {showCalendar && (
-
-
-
- )}
-
-
-
-
- {/* ── FOOTER ─ Using #042f3a for BG ───────────────────────────────────── */}
-
-
-
-
-
-
-
-
-
-
-
-
© 2026 Next Generation of Unity. All rights reserved.
-
-
-
-
-
+
+
+ {/* Layout renders the nav once and swaps page content into */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
);
}
diff --git a/src/Fall Logo.svg b/src/assets/Fall Logo.svg
similarity index 100%
rename from src/Fall Logo.svg
rename to src/assets/Fall Logo.svg
diff --git a/src/NGU_Logo.svg b/src/assets/NGU_Logo.svg
similarity index 100%
rename from src/NGU_Logo.svg
rename to src/assets/NGU_Logo.svg
diff --git a/src/NGU_Logo_WhiteBG.svg b/src/assets/NGU_Logo_WhiteBG.svg
similarity index 100%
rename from src/NGU_Logo_WhiteBG.svg
rename to src/assets/NGU_Logo_WhiteBG.svg
diff --git a/src/assets/event-logos/fall-retreat-logo.svg b/src/assets/event-logos/fall-retreat-logo.svg
new file mode 100644
index 0000000..33f133f
--- /dev/null
+++ b/src/assets/event-logos/fall-retreat-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/event-logos/ngu-logo-white-bg.svg b/src/assets/event-logos/ngu-logo-white-bg.svg
new file mode 100644
index 0000000..3202fa3
--- /dev/null
+++ b/src/assets/event-logos/ngu-logo-white-bg.svg
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Next Generation
+ of Unity
+
+
+
+
diff --git a/src/bannerConfig.js b/src/bannerConfig.js
new file mode 100644
index 0000000..d3d579a
--- /dev/null
+++ b/src/bannerConfig.js
@@ -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,
+};
diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx
new file mode 100644
index 0000000..b723353
--- /dev/null
+++ b/src/components/Banner.tsx
@@ -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 (
+
+
+ {SITE_BANNER.content.map((part, i) => {
+ if (!part.href) return {part.text} ;
+ return part.external ? (
+
+ {part.text}
+
+ ) : (
+
+ {part.text}
+
+ );
+ })}
+
+
+ {SITE_BANNER.dismissible && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 0000000..a4ea3a2
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -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" }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const FacebookIcon = () => (
+
+
+
+);
+
+const DiscordIcon = () => (
+
+
+
+);
+
+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 (
+
+
+
+ {/* Brand */}
+
+
+
+
+
+
+ {/* Site map — mirrors both nav tiers */}
+
+
+ Site Map
+
+ {PAGE_LINKS.map((page) => {
+ const sections = PAGE_SECTIONS[page.path] ?? [];
+ return (
+
+
+ {page.label}
+
+
+ {sections.length > 0 && (
+
+ {sections.map((section) => (
+
+
+ {section.label.replace(/^#/, "")}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+
+ {giveAction && (
+
+ Support Us
+
+ )}
+
+
+ {/* Socials + get in touch */}
+
+
+
+
Get in Touch
+
+ {Get_In_Touch.map((link) => (
+
+ {link.external ? (
+
+ {link.label}
+
+ ) : (
+
+ {link.label}
+
+ )}
+
+ ))}
+
+
+
+
+
+
© 2026 Next Generation of Unity. All rights reserved.
+
+ {Footer_Links.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
new file mode 100644
index 0000000..2073f0a
--- /dev/null
+++ b/src/components/Layout.tsx
@@ -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 ? (
+
+ {item.label}
+
+ ) : (
+
+ {item.label}
+
+ );
+}
+
+const navActions = NAV_ACTIONS as NavActionItem[];
+
+/* ── Layout ──────────────────────────────────────────────── */
+
+export default function Layout() {
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [activeSection, setActiveSection] = useState
(null);
+ const [headerHeight, setHeaderHeight] = useState(72);
+ const headerRef = useRef(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 (
+
+
+ {/* ── NAV ─────────────────────────────────────────────── */}
+
+
+ {/* Tier 0 — site-wide announcement */}
+
+
+ {/* Tier 1 — site navigation */}
+
+
+
+
+
+
+
+ {PAGE_LINKS.map((page) => (
+
+ `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}
+
+ ))}
+
+
+
+
+
+ {navActions.map((item) => (
+
+ ))}
+
+
+
setMobileMenuOpen(!mobileMenuOpen)}
+ >
+
+
+
+
+
+
+
+ {/* Tier 2 — sections of the current page */}
+ {sections.length > 0 && (
+
+ {activePage?.label}
+
+ {/* Separates the page label from its sections */}
+
+
+
+
+ )}
+
+
+ {/* Keeps page content clear of the fixed header */}
+
+
+ {/* Page content */}
+
+
+
+
+ {/*Site Footer applied to every page*/}
+
+
+ {/* Mobile menu */}
+ {mobileMenuOpen && (
+
+
setMobileMenuOpen(false)}
+ >
+ ×
+
+
+
+ {PAGE_LINKS.map((page) => {
+ const pageSections = PAGE_SECTIONS[page.path] ?? [];
+ const isActive = location.pathname === page.path;
+
+ return (
+
+
+ `block text-2xl font-bold py-3 transition-colors ${
+ isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
+ }`
+ }
+ >
+ {page.label}
+
+
+ {/* Sections nest under the page they belong to */}
+ {isActive && pageSections.length > 0 && (
+
+ )}
+
+ );
+ })}
+
+
+
+ {navActions.map((item) => (
+ setMobileMenuOpen(false)}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx
new file mode 100644
index 0000000..63ce434
--- /dev/null
+++ b/src/components/PageShell.tsx
@@ -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 and no footer here — Layout provides both, so a page
+ using this shell drops straight into the router's Outlet.
+
+ Usage:
+ , // optional, right of the heading
+ content: ,
+ },
+ ]}
+ />
+ ═══════════════════════════════════════════════════════════════ */
+
+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 (
+
+ {/* Heading — left aligned, optional actions on the right */}
+
+
+
+
+ {title}
+
+ {blurb &&
{blurb}
}
+
+ {actions}
+
+
+
+
+ {content}
+
+ );
+}
+
+export default function PageShell({ title, intro, sections }) {
+ return (
+ <>
+ {/* Page header */}
+
+
+
+ {title}
+
+ {intro &&
{intro}
}
+
+
+
+ {sections.map((section, i) => (
+
+ {/* Thin solid banner between sections */}
+
+
+ {i === sections.length - 1 && (
+
+ )}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/events.js b/src/events.js
new file mode 100644
index 0000000..06a49ce
--- /dev/null
+++ b/src/events.js
@@ -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;
diff --git a/src/navConfig.js b/src/navConfig.js
new file mode 100644
index 0000000..f44e06e
--- /dev/null
+++ b/src/navConfig.js
@@ -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,
+ },
+];
diff --git a/src/pages/Chapters.tsx b/src/pages/Chapters.tsx
new file mode 100644
index 0000000..3016e46
--- /dev/null
+++ b/src/pages/Chapters.tsx
@@ -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
+ — 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 (
+ setHovered(primary.id)}
+ onMouseLeave={() => setHovered(null)}
+ style={{ cursor: "pointer" }}
+ opacity={dimmed ? 0.25 : 1}
+ className="transition-opacity duration-200"
+ >
+
+ {STATE_NAMES[code] || code} —{" "}
+ {secondary ? `${primary.name} / ${secondary.name}` : primary.name}
+ {count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
+
+
+
+
+
+
+
+
+ {split && (
+
+ )}
+
+
+
+
+
+ {label || code}
+
+
+ {count > 0 && (
+
+ )}
+
+ );
+}
+
+function RegionMap(props) {
+ const size = TILE - PAD * 2;
+ // `onPick` rides along in props to each Tile via the spread below.
+
+ return (
+
+ {/* Wide areas (Canada) */}
+ {Object.entries(MAP_BANDS).map(([code, band]) => {
+ const [c1, c2] = band.cols;
+ return (
+
+ );
+ })}
+
+ {/* States */}
+ {Object.entries(STATE_GRID).map(([code, [col, row]]) => (
+
+ ))}
+
+ );
+}
+
+function LegendButton({ group, selected, setSelected, hovered, setHovered }) {
+ const on = selected === group.id || hovered === group.id;
+ return (
+ 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,
+ }}
+ >
+
+ {group.name}
+
+ );
+}
+
+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 (
+
+
+ US Regions
+
+
+ {us.map(g => (
+
+ ))}
+
+
+
+
+
+ International
+
+
+ {intl.map(g => (
+
+ ))}
+ {selected && (
+ setSelected(null)}
+ className="py-1.5 px-3 rounded-lg text-sm font-700 text-[#4a6b72] underline"
+ >
+ Show all
+
+ )}
+
+
+ );
+}
+
+function GroupBlock({
+ group,
+ selected,
+ setSelected,
+ setHovered,
+ indent,
+ groupRefs,
+ chapterRefs,
+}) {
+ const chapters = chaptersIn(group.id);
+ const on = selected === group.id;
+
+ return (
+ groupRefs && (groupRefs.current[group.id] = el)}
+ className="transition-opacity duration-200"
+ style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
+ >
+
setSelected(on ? null : group.id)}
+ onMouseEnter={() => setHovered(group.id)}
+ onMouseLeave={() => setHovered(null)}
+ className="w-full flex items-baseline gap-2 text-left py-2"
+ >
+
+
+ {group.name}
+
+
+ {chapters.length || "—"}
+
+
+
+ {groupSubtext(group) && (
+
+ {groupSubtext(group)}
+
+ )}
+
+ {chapters.length === 0 ? (
+
+ No chapters yet — interested in starting one?
+
+ ) : (
+
+ )}
+
+ );
+}
+
+
+/* ═══════════════════════════════════════════════════════════════
+ 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 (
+ setFailed(true)}
+ className={`${size} object-contain rounded-xl shrink-0`}
+ />
+ );
+ }
+
+ return (
+
+ {initials}
+
+ );
+}
+
+function ChapterCard({ chapter, color, open, onOpen }) {
+ return (
+
+
+
+
+
{chapter.name}
+
{chapter.city}
+ {chapter.meets && (
+
{chapter.meets}
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
{chapter.name}
+
{chapter.city}
+
+
+
+ ×
+
+
+
+ {chapter.about && (
+
{chapter.about}
+ )}
+
+ {rows.length > 0 && (
+
+ {rows.map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+ )}
+
+ {(chapter.link || chapter.contact) && (
+
+ )}
+
+ );
+}
+
+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 (
+
+ {populated.map(({ group, list }) => (
+
+
+
+
+ {group.name}
+
+ {list.length}
+
+ {groupSubtext(group) && (
+ {groupSubtext(group)}
+ )}
+
+ {/* Detail panel sits above this region's grid, and only
+ when the open chapter belongs to this region. */}
+ {openChapter && groupOf(openChapter)?.id === group.id && (
+ setOpenId(null)}
+ />
+ )}
+
+
+ {list.map(c => (
+ setOpenId(openId === c.id ? null : c.id)}
+ />
+ ))}
+
+
+ ))}
+
+ {empty.length > 0 && (
+
+ No chapters yet in {empty.map(g => g.name).join(", ")} — interested in
+ starting one?
+
+ )}
+
+ );
+}
+
+/* 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 (
+
+ {[
+ ["map", "Map"],
+ ["grid", "Grid"],
+ ].map(([id, label]) => (
+ setView(id)}
+ aria-pressed={view === id}
+ className="py-2 px-4 font-700 text-sm transition-colors duration-200"
+ style={btn(view === id)}
+ >
+ {label}
+
+ ))}
+
+ );
+}
+
+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 ;
+ }
+
+ 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. */
+
+
+ {/* Map */}
+
+
+
+
+ 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.
+
+
+
+ {/* 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. */}
+
+
US Regions
+ {DOMESTIC.map(g => (
+
+ ))}
+
+
+ International
+
+ {INTERNATIONAL.map(g => (
+
+ ))}
+
+
+ {VIRTUAL.map(g => (
+
+ ))}
+
+
+
+
+ );
+}
+
diff --git a/src/pages/Community.tsx b/src/pages/Community.tsx
new file mode 100644
index 0000000..65133e1
--- /dev/null
+++ b/src/pages/Community.tsx
@@ -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: (
+
+ ),
+ content: ,
+ },
+ {
+ id: "virtual",
+ title: "Virtual Community",
+ blurb: "Connect from anywhere, no chapter nearby required.",
+ accent: "#aac992",
+ background: "#ffffff",
+ content: (
+
+ {/* Virtual community content */}
+
+ ),
+ },
+ {
+ id: "partners",
+ title: "Partner Organizations",
+ blurb: "Organizations we collaborate with across the Unity movement.",
+ accent: "#7a5ea8",
+ background: "#eef9fb",
+ content: (
+
+ {/* Partner organizations content */}
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/pages/Feedback.tsx b/src/pages/Feedback.tsx
new file mode 100644
index 0000000..8d23da8
--- /dev/null
+++ b/src/pages/Feedback.tsx
@@ -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 (
+
+ Optional
+
+ );
+}
+
+/* ── Type picker ─────────────────────────────────────────────── */
+
+function TypePicker({ value, onChange }) {
+ return (
+
+
+ What kind of feedback is this?
+
+
+ Pick the closest fit. It helps us route it to the right person.
+
+
+
+ {FEEDBACK_TYPES.map((type) => {
+ const selected = value === type.id;
+ return (
+
+ onChange(type.id)}
+ className="peer sr-only"
+ />
+
+
+
+ {type.label}
+
+
+
+
+ {type.hint}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/* ── 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 (
+
+
+ Thanks — we've got it
+
+
+ {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."}
+
+
+ Send more feedback
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/* ── 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: (
+
+
+
+ ),
+ },
+];
+
+export default function FeedbackPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/Giving.tsx b/src/pages/Giving.tsx
new file mode 100644
index 0000000..e10290f
--- /dev/null
+++ b/src/pages/Giving.tsx
@@ -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 (
+
+
+ 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.
+
+
+
+ Give now
+
+
+
+ If you would like to send a check, please email our Board Treasurer at treasurer@nextgenerationofunity.org to get an address.
+
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ 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: ,
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..af2ed62
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -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 }) => (
+
+
+
+
+
+
+
+
+);
+
+const InstagramIcon = ({ id = "ig-gradient" }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const FacebookIcon = () => (
+
+
+
+);
+
+const DiscordIcon = () => (
+
+
+
+);
+
+{/* 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) => (
+
+ {char === " " ? "\u00A0" : char}
+
+ ))}
+ >
+ );
+}
+
+
+
+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 ─────────────────────────────────────────────── */}
+
+ {/* Floating doves */}
+
+
+
+
+
+
+ Next Generation
+
+
+
+
+
+
+ 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.
+
+
+ We are the future of the Unity Movement.
+
+
+
+
+
+
+ {/* #About ─────────────────────────────────────── */}
+
+
+
+
A Ministry Designed For Young Adults
+
+ NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
+
+
+
+
+ {[
+ { 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) => (
+
+
{card.icon}
+
{card.title}
+
{card.desc}
+
+ ))}
+
+
+
+ {/* #Events ─────────────────────────────────────────────── */}
+
+
+
+
+ {isPast ? "Past Events" : "Upcoming Events"}
+
+
+
+
+ {/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
+
+ {/* Masked viewport: everything outside the fade gradient is invisible */}
+
+ {/* Sliding track */}
+
+ {EVENTS.map((ev, i) => {
+ const active = i === index;
+ const past = ev.status === "past";
+ const color = ev.color || TEAL;
+ return (
+
!active && setIndex(i)}
+ aria-hidden={!active}
+ >
+
+
+
+
+
+
{ev.title}
+ {ev.theme && (
+
"{ev.theme}"
+ )}
+
{ev.date}
+
{ev.location}
+
+
+ {ev.image && (
+
+ )}
+
+
+
+ {ev.desc_a &&
{ev.desc_a}
}
+ {ev.desc_b &&
{ev.desc_b}
}
+
+ {ev.links.length > 0 ? (
+
+ ) : (
+ past ? (
+
+ This event has concluded, thank you to everyone who joined us!
+
+ ) : (
+ <>
+
+ Registration has not opened yet, follow our instagram for more details.
+
+
+
+ @nextgenerationunity
+
+ >
+ )
+ )}
+
+
+
+ );
+ })}
+
+
+
+ {/* Arrows — outside the masked element so they never fade */}
+
0)}
+ >
+ ‹
+
+
+ ›
+
+
+
+
+ {/* Dot indicators */}
+
+ {EVENTS.map((e, i) => (
+ 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",
+ }}
+ />
+ ))}
+
+
+
+ · More events coming soon, stay connected for announcements ·
+
+
+
+ {/* #Connect -------------------------------------------- */}
+
+
+
+
+ Ready to Connect?
+
+
Find your place in the NGU community
+
+
+
+ {[
+ { 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 => (
+
+
+
{item.label}
+
{item.desc}
+
+ {/* Arrow */}
+
+
+
+
+ ))}
+
+
+
+
+
+ 📅 View NGU Calendar
+
+
+
+
+
+
+
+
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"}
+
+
+
+ {showCalendar && (
+
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/pages/Leadership.tsx b/src/pages/Leadership.tsx
new file mode 100644
index 0000000..6767de7
--- /dev/null
+++ b/src/pages/Leadership.tsx
@@ -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: (
+
+ {/* Board & staff content */}
+
+ ),
+ },
+ {
+ id: "retreat-team",
+ title: "Retreat Team",
+ blurb: "Volunteers who plan and lead our gatherings throughout the year.",
+ accent: "#aac992",
+ background: "#ffffff",
+ content: (
+
+ {/* Retreat team content */}
+
+ ),
+ },
+ {
+ id: "committees",
+ title: "Committees & Volunteers",
+ blurb: "Additional roles and contributors.",
+ accent: "#7a5ea8",
+ background: "#eef9fb",
+ content: (
+
+ {/* Other roles content */}
+
+ ),
+ },
+ {
+ id: "contact",
+ title: "Contact Us",
+ blurb: "Reach out to our staff",
+ accent: "#004c52",
+ background: "#ffffff",
+ content: (
+
+ ),
+ },
+];
+
+export default function Leadership() {
+ return (
+
+ );
+}
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
new file mode 100644
index 0000000..50e724e
--- /dev/null
+++ b/src/pages/NotFound.tsx
@@ -0,0 +1,16 @@
+import { Link } from "react-router-dom";
+
+export default function NotFound() {
+ return (
+
+ That page isn't here
+
+ The link may be out of date.{" "}
+
+ Go to the home page
+
+ .
+
+
+ );
+}
diff --git a/src/pages/Other.tsx b/src/pages/Other.tsx
new file mode 100644
index 0000000..1382beb
--- /dev/null
+++ b/src/pages/Other.tsx
@@ -0,0 +1,13 @@
+export default function Other() {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx
new file mode 100644
index 0000000..fd297d9
--- /dev/null
+++ b/src/pages/Privacy.tsx
@@ -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: (
+
+ {/* Policy text goes here */}
+
+ ),
+ },
+];
+
+export default function PrivacyPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/Resources.tsx b/src/pages/Resources.tsx
new file mode 100644
index 0000000..a65345c
--- /dev/null
+++ b/src/pages/Resources.tsx
@@ -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: (
+
+ {/* Unity teachings content */}
+
+ ),
+ },
+ {
+ id: "branding",
+ title: "Branding & Marketing",
+ blurb: "Logos, colors, templates, and guidelines for representing NGU.",
+ accent: "#aac992",
+ background: "#ffffff",
+ content: (
+
+ {/* Branding & marketing content */}
+
+ ),
+ },
+ {
+ id: "partner",
+ title: "Partner Resources",
+ blurb: "Material for the organizations we collaborate with.",
+ accent: "#7a5ea8",
+ background: "#eef9fb",
+ content: (
+
+ {/* Partner resources content */}
+
+ ),
+ },
+];
+
+export default function ResourcesPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/Retreats.tsx b/src/pages/Retreats.tsx
new file mode 100644
index 0000000..23253f8
--- /dev/null
+++ b/src/pages/Retreats.tsx
@@ -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 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 (
+ 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" }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+/* ═══════════════════════════════════════════════════════════════
+ 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 && (
+
+ {ev.desc_a}
+
+ )}
+ {ev.desc_b && (
+
+ {ev.desc_b}
+
+ )}
+ >
+ );
+
+ return (
+
+
+ {compact ? (
+ /* ── GRID CARD: layout driven by .ev-grid container queries ── */
+
+
+
+
{ev.title}
+ {ev.theme && (
+
"{ev.theme}"
+ )}
+ {ev.date &&
{ev.date}
}
+ {ev.location &&
{ev.location}
}
+
+
+
{descriptions}
+
+ ) : (
+ /* ── FULL: details left, large event logo right ── */
+ <>
+
+
+
+
{ev.title}
+ {ev.theme && (
+
"{ev.theme}"
+ )}
+ {ev.date &&
{ev.date}
}
+ {ev.location &&
{ev.location}
}
+
+
+
+
+
+ {descriptions}
+ >
+ )}
+
+ {/* Footer — mt-auto pins it to the bottom so buttons line up
+ across every card in a grid row. */}
+ {ev.links.length > 0 ? (
+
+ ) : past ? (
+
+ This event has concluded — thank you to everyone who joined us!
+
+ ) : (
+
+
+ {igHandle
+ ? "Registration has not opened yet, follow our instagram for more details."
+ : "Registration has not opened yet — check back soon for more details."}
+
+ {igHandle && (
+
+
+ {igHandle}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ VIEW TOGGLE — carousel / grid segmented control
+ ═══════════════════════════════════════════════════════════════ */
+function ViewToggle({ view, setView, accent }) {
+ const btn = active => ({
+ background: active ? accent : "transparent",
+ color: active ? "#ffffff" : accent,
+ });
+
+ return (
+
+
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")}
+ >
+
+
+
+
+ Carousel
+
+
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")}
+ >
+
+
+
+
+
+
+ Grid
+
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ 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 (
+
+ {events.length === 0 ? (
+
+ · Events coming soon, stay connected for announcements ·
+
+ ) : view === "grid" ? (
+ /* ── GRID VIEW — upcoming first, past events collapsed below ── */
+
+ {upcoming.length > 0 && (
+
+ {upcoming.map(ev => (
+
+ ))}
+
+ )}
+
+ {pastEvents.length > 0 && (
+
+
+ Past Events
+
+
+ {/* Collapsed: clipped to PAST_PEEK and faded out at the
+ bottom. Expanded: full height, no mask. */}
+
+
+ {pastEvents.map(ev => (
+
+ ))}
+
+
+
+
+
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})`}
+
+
+
+
+
+
+ )}
+
+ ) : (
+ /* ── CAROUSEL VIEW ── */
+ <>
+
+
+
+ {events.map((ev, i) => {
+ const active = i === index;
+ return (
+
!active && setIndex(i)}
+ aria-hidden={!active}
+ >
+
+
+ );
+ })}
+
+
+
+ {events.length > 1 && (
+ <>
+
0)}
+ >
+ ‹
+
+
+ ›
+
+ >
+ )}
+
+
+ {events.length > 1 && (
+
+ {events.map((e, i) => (
+ 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",
+ }}
+ />
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ 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 && (
+ setView(section.id, value)}
+ accent={section.accent}
+ />
+ ),
+ content: ,
+ };
+ });
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/pages/Terms.tsx b/src/pages/Terms.tsx
new file mode 100644
index 0000000..a24467d
--- /dev/null
+++ b/src/pages/Terms.tsx
@@ -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: (
+
+ {/* Terms text goes here */}
+
+ ),
+ },
+];
+
+export default function TermsPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/chapters.js b/src/pages/chapters.js
new file mode 100644
index 0000000..ae27e9f
--- /dev/null
+++ b/src/pages/chapters.js
@@ -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");