feat(website): add club curated routes, live squad chat, and portal navigation

This commit is contained in:
Developer 2026-10-02 12:39:38 -04:00
parent 368858670d
commit c956ffd7bd
20 changed files with 3315 additions and 92 deletions

View File

@ -49,7 +49,7 @@ export default function AccountPage() {
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-borderLight to-slate-900 border border-brand-cyan/40 p-1 flex items-center justify-center shadow-xl">
<span className="font-mono text-3xl font-extrabold text-brand-cyan tracking-wider">{initials}</span>
</div>
<span className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-brand-emerald border-2 border-brand-bg flex items-center justify-center" title="Active OBD-II Link">
<span className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-slate-500 border-2 border-brand-bg flex items-center justify-center" title="Motorcycle hardware telemetry is not available">
<svg className="w-3 h-3 text-black fill-current" viewBox="0 0 20 20"><path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"></path></svg>
</span>
</div>
@ -176,10 +176,10 @@ export default function AccountPage() {
<span className="w-2.5 h-2.5 rounded-full bg-brand-emerald animate-pulse"></span>
</div>
<div className="mt-3 flex items-baseline space-x-2">
<span className="text-xl font-bold text-white font-mono">OBD-II CAN v3.2</span>
<span className="text-xl font-bold text-white font-mono">Not connected</span>
</div>
<div className="mt-2 text-xs text-brand-emerald font-mono flex items-center space-x-1.5">
<span>Active Sync @ 10Hz Multi-GNSS</span>
<span>OBD-II / CAN telemetry unavailable</span>
</div>
</div>
</section>
@ -203,13 +203,13 @@ export default function AccountPage() {
<div>
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-5">
<div>
<span className="text-xs font-mono tracking-normal text-brand-cyan">Active Subscription Plan</span>
<span className="text-xs font-mono tracking-normal text-brand-cyan">Subscription billing</span>
<h3 className="text-xl font-bold text-white flex items-center space-x-2 mt-0.5">
<span>Pro Telemetry Tier</span>
<span className="text-xs font-mono bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/30 font-medium">Active • Renews Sept 2027</span>
<span>Not connected</span>
<span className="text-xs font-mono bg-slate-700 text-slate-300 px-2 py-0.5 rounded border border-slate-600 font-medium">Web billing unavailable</span>
</h3>
</div>
<span className="text-xs font-mono text-slate-400">$189/yr billed</span>
<span className="text-xs font-mono text-slate-400">No active plan data</span>
</div>
{/* Inclusions Pills */}
<div className="mb-5">
@ -217,33 +217,31 @@ export default function AccountPage() {
<div className="flex flex-wrap gap-2 text-xs font-mono">
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
<span>10Hz Multi-GNSS Logging</span>
<span>Phone-based ride recording</span>
</span>
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
<span>ECU CAN Bus Full Trace</span>
<span>OBD-II / ECU telemetry unavailable</span>
</span>
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
<span>Unlimited Cloud Storage</span>
<span>Cloud sync status shown below</span>
</span>
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
<span>Crash SOS Beacon (&lt;150ms)</span>
<span>Emergency dispatch is not provided</span>
</span>
</div>
</div>
{/* Linked Hardware Details */}
<div className="p-3.5 bg-brand-cardAlt rounded-lg border border-brand-border mb-4 font-mono text-xs flex flex-col sm:flex-row justify-between gap-3">
<div>
<span className="text-slate-400 block text-[11px]">Registered Dongle</span>
<span className="font-bold text-white">MotorSignal CAN-Link OBD-II</span>
<div className="text-[11px] text-slate-400 mt-0.5">SN: MS-LINK-9021-X</div>
<span className="text-slate-400 block text-[11px]">Motorcycle hardware</span>
<span className="font-bold text-white">Not available</span>
</div>
<div className="sm:text-right">
<span className="text-slate-400 block text-[11px]">Firmware Status</span>
<span className="text-brand-emerald font-semibold">v2.4.1 (Current)</span>
<div className="text-[11px] text-slate-400 mt-0.5">Last Check: Today, 09:12 AM</div>
<span className="text-slate-400 block text-[11px]">Device status</span>
<span className="text-slate-300 font-semibold">No device linked</span>
</div>
</div>
</div>
@ -263,10 +261,10 @@ export default function AccountPage() {
<span>Founder Portal</span>
</Link>
<button className="px-4 py-2 rounded-lg bg-brand-cardAlt border border-brand-border hover:border-slate-500 text-xs font-semibold text-white transition">
Manage Plan &amp; Invoices
Billing unavailable
</button>
<button className="px-4 py-2 rounded-lg bg-cyan-500/10 text-brand-cyan border border-brand-cyan/40 hover:bg-cyan-500/20 text-xs font-semibold transition">
Run Hardware Diagnostic
Hardware diagnostics unavailable
</button>
</div>
</div>
@ -346,8 +344,8 @@ export default function AccountPage() {
<div className="lg:col-span-5 glass-card rounded-xl border border-brand-border p-6 flex flex-col justify-between" data-purpose="safety-and-security">
<div>
<div className="border-b border-brand-border pb-4 mb-4">
<h3 className="text-lg font-bold text-white">Security &amp; Rider SOS</h3>
<p className="text-xs font-mono text-slate-400 mt-0.5">Emergency contact routing and app account link</p>
<h3 className="text-lg font-bold text-white">Account &amp; Security</h3>
<p className="text-xs font-mono text-slate-400 mt-0.5">Account-link status and security settings</p>
</div>
<div className="space-y-4 text-xs font-mono">
{/* Google App Link */}

View File

@ -0,0 +1,9 @@
'use client';
import { use } from 'react';
import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page';
export default function ChatPage({ params }: { params: Promise<{ clubId: string }> }) {
const { clubId } = use(params);
return <FounderCommunityPage clubId={clubId} section="chat" />;
}

View File

@ -0,0 +1,9 @@
'use client';
import { use } from 'react';
import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page';
export default function RoutesPage({ params }: { params: Promise<{ clubId: string }> }) {
const { clubId } = use(params);
return <FounderCommunityPage clubId={clubId} section="routes" />;
}

View File

@ -6,7 +6,7 @@ import Link from 'next/link';
export default function ClubsPage() {
return (
<div className="flagship-page text-on-surface bg-surface min-h-screen">
<div className="flex flex-col w-full">
<div hidden>
{/* Top Command Matrix Hero */}
<section className="relative w-full py-space-4xl overflow-hidden bg-surface-container-lowest">
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[450px] bg-primary-container/10 blur-[130px] rounded-full pointer-events-none"></div>
@ -595,6 +595,13 @@ export default function ClubsPage() {
</div>
</section>
</div>
<section className="max-w-container-max mx-auto px-gutter-desktop py-space-4xl" aria-labelledby="club-capabilities-title">
<div className="max-w-3xl mx-auto rounded-2xl bg-surface-container-low p-space-2xl text-center">
<h1 id="club-capabilities-title" className="font-headline-xl text-headline-xl text-on-surface mb-space-md">Club tools, with a clear feature status</h1>
<p className="font-body-lg text-body-lg text-on-surface-variant mb-space-lg">The Founder Portal supports member and Wall management, Stats, and moderation tools for membership and Wall posts. Routes, chat, post reports, CAN-bus diagnostics, crash dispatch, and off-grid mesh are not available. Directory and telemetry examples on this page were sample content, not live club data.</p>
<Link className="inline-flex items-center justify-center px-space-xl py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm" href="/clubs/manage">Open Founder Portal</Link>
</div>
</section>
</div>
);

View File

@ -7,11 +7,8 @@ export default function FeaturesPage() {
useEffect(() => {
let speed = 174;
let lean = 52.8;
let countdown = 8;
const speedEl = document.getElementById('speed-val');
const leanEl = document.getElementById('lean-val');
const countdownEl = document.getElementById('sos-countdown');
const i1 = setInterval(() => {
if (speedEl) {
@ -26,17 +23,8 @@ export default function FeaturesPage() {
}
}, 800);
const i2 = setInterval(() => {
if (countdownEl) {
countdown--;
if (countdown < 0) countdown = 8;
countdownEl.textContent = '00:0' + countdown + 's';
}
}, 1000);
return () => {
clearInterval(i1);
clearInterval(i2);
};
}, []);
@ -64,7 +52,7 @@ export default function FeaturesPage() {
Surgical data precision for every apex, straightaway, and canyon.
</h1>
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-2xl">
Explore the full spectrum of MotorSignal hardware integration, real-time cockpit intelligence, crash telemetry, and race-grade analysis calibrated for professional riders and trackside telemetry engineers.
Explore phone-based ride recording, performance tools, and rider community features available in MotorSignal today.
</p>
</div>
<div className="flex flex-col gap-space-sm pb-space-xs w-full max-w-sm">
@ -92,10 +80,10 @@ export default function FeaturesPage() {
<span className="text-outline">02</span> Lap Analysis &amp; Sectors
</a>
<a className="flex-shrink-0 px-space-md py-2 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface-variant hover:text-on-surface font-label-caps text-label-caps transition-all shadow-sm flex items-center gap-space-2xs" href="#emergency">
<span className="text-outline">03</span> Crash &amp; SOS Beacon
<span className="text-outline">03</span> Ride Records &amp; Road Alerts
</a>
<a className="flex-shrink-0 px-space-md py-2 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface-variant hover:text-on-surface font-label-caps text-label-caps transition-all shadow-sm flex items-center gap-space-2xs" href="#canbus">
<span className="text-outline">04</span> CAN-bus &amp; ECU Stream
<span className="text-outline">04</span> Phone Sensor Telemetry
</a>
<a className="flex-shrink-0 px-space-md py-2 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface-variant hover:text-on-surface font-label-caps text-label-caps transition-all shadow-sm flex items-center gap-space-2xs" href="#cloud-replay">
<span className="text-outline">05</span> Cloud Sync &amp; Replay
@ -432,10 +420,10 @@ export default function FeaturesPage() {
<span className="h-1.5 w-1.5 rounded-full bg-error"></span>
</div>
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
Emergency Impact Detection &amp; SOS Beacon
Ride Records &amp; Rider-Reported Hazards
</h2>
<p className="font-body-lg text-body-lg text-on-surface-variant">
Riding at the limit demands uncompromising safety automation. MotorSignal continuously parses 6-axis G-force spikes and gyro inversions to trigger life-saving protocols in milliseconds.
Record ride telemetry on your phone and share road hazards with other riders. MotorSignal does not automatically detect crashes or contact emergency services.
</p>
{/* Feature Bullets */}
<div className="flex flex-col gap-space-sm my-space-xs">
@ -444,9 +432,9 @@ export default function FeaturesPage() {
<span className="material-symbols-outlined text-xl">sensors</span>
</div>
<div>
<h4 className="font-headline-sm text-headline-sm text-on-surface">&lt;150ms High-G Impact Trigger</h4>
<h4 className="font-headline-sm text-headline-sm text-on-surface">Local Ride Recording</h4>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Tri-axial MEMS sensors distinguish between road vibration, pothole shocks, and actual bike separation or highside events.
Ride recording uses your phone sensors and remains available when cloud services are offline.
</p>
</div>
</div>
@ -455,9 +443,9 @@ export default function FeaturesPage() {
<span className="material-symbols-outlined text-xl">satellite_alt</span>
</div>
<div>
<h4 className="font-headline-sm text-headline-sm text-on-surface">Dual Cellular &amp; Satellite Mesh Dispatch</h4>
<h4 className="font-headline-sm text-headline-sm text-on-surface">Rider-Submitted Road Alerts</h4>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Transmits GPS coordinates, rider blood type, impact force severity, and local EMS telemetry package via cellular carrier failover.
Report a road hazard or incident from the app. Alerts are not emergency dispatch and require network access to share.
</p>
</div>
</div>
@ -466,9 +454,9 @@ export default function FeaturesPage() {
<span className="material-symbols-outlined text-xl">lock_reset</span>
</div>
<div>
<h4 className="font-headline-sm text-headline-sm text-on-surface">Cryptographic Cloud Blackbox Preservation</h4>
<h4 className="font-headline-sm text-headline-sm text-on-surface">Your Ride Data</h4>
<p className="font-body-sm text-body-sm text-on-surface-variant">
The trailing 30 seconds of pre-impact CAN-bus throttle, braking, lean, and camera triggers are locked and preserved to encrypted cloud storage.
Review recorded ride history and export supported ride data. No CAN-bus or camera data is captured by this feature.
</p>
</div>
</div>
@ -477,11 +465,11 @@ export default function FeaturesPage() {
<div className="flex items-center gap-space-md pt-space-xs">
<div className="px-space-md py-2 rounded-xl bg-surface-container flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-lg">verified</span>
<span className="font-label-caps text-label-caps text-on-surface">FIM Telemetry Spec 2025</span>
<span className="font-label-caps text-label-caps text-on-surface">Phone Sensor Telemetry</span>
</div>
<div className="px-space-md py-2 rounded-xl bg-surface-container flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-lg">shield</span>
<span className="font-label-caps text-label-caps text-on-surface">ISO 26262 ASIL-B Safety</span>
<span className="font-label-caps text-label-caps text-on-surface">Offline-Ready Recording</span>
</div>
</div>
</div>
@ -490,28 +478,28 @@ export default function FeaturesPage() {
<div className="flex items-center justify-between pb-space-md border-b-0 mb-space-md">
<div className="flex items-center gap-space-xs">
<span className="w-3 h-3 rounded-full bg-error animate-ping"></span>
<span className="font-label-caps text-label-caps text-error">Tactical Crash Simulator Demo</span>
<span className="font-label-caps text-label-caps text-error">Illustrative UI Preview — Not an Emergency Service</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Hardware Test v4</span>
<span className="font-code-mono text-code-mono text-outline">Sample Data</span>
</div>
<div className="bg-surface-container-lowest p-space-lg rounded-xl mb-space-lg flex flex-col items-center justify-center text-center">
<div className="w-20 h-20 rounded-full bg-error/10 flex items-center justify-center mb-space-sm text-error">
<span className="material-symbols-outlined text-4xl">warning</span>
</div>
<span className="font-headline-lg text-headline-lg text-on-surface mb-space-2xs">Impact Detected: +4.8G</span>
<span className="font-headline-lg text-headline-lg text-on-surface mb-space-2xs">Road Alert Example</span>
<p className="font-body-sm text-body-sm text-on-surface-variant max-w-sm mb-space-md">
Emergency SOS countdown initialized. Coordinates resolved. Dispatch pending in:
This preview is illustrative. It does not detect crashes, transmit an SOS, or contact responders.
</p>
<div className="font-telemetry-metric-xl text-telemetry-metric-xl text-error mb-space-sm" id="sos-countdown">
00:08s
<div className="font-label-caps text-label-caps text-error mb-space-sm">
SAMPLE
</div>
<div className="flex gap-space-sm w-full max-w-xs">
<button className="flex-1 py-2 rounded-xl bg-surface-bright text-on-surface font-label-md text-label-md hover:bg-surface-variant transition-all">
I Am OK (Cancel)
</button>
<button className="flex-1 py-2 rounded-xl bg-error text-on-error font-label-md text-label-md hover:opacity-90 transition-all">
Trigger Now
</button>
<span className="flex-1 py-2 rounded-xl bg-surface-bright text-on-surface font-label-md text-label-md">
Close Preview
</span>
<span className="flex-1 py-2 rounded-xl bg-error text-on-error font-label-md text-label-md">
Report a Hazard
</span>
</div>
</div>
<div className="bg-surface-container-low p-space-md rounded-lg flex flex-col gap-space-xs font-code-mono text-code-mono text-on-surface-variant">
@ -520,12 +508,12 @@ export default function FeaturesPage() {
<span className="text-on-surface">43.9975° N, 11.3714° E (Mugello S3)</span>
</div>
<div className="flex justify-between">
<span>Rider Profile:</span>
<span className="text-on-surface">A-Positive // Emergency Contact Linked</span>
<span>Availability:</span>
<span className="text-on-surface">Sample content only</span>
</div>
<div className="flex justify-between">
<span>Data Sent:</span>
<span className="text-secondary-fixed">Trail Crash Packet (CAN-Bus 250kbps)</span>
<span>Emergency dispatch:</span>
<span className="text-secondary-fixed">Not provided</span>
</div>
</div>
</div>
@ -538,18 +526,22 @@ export default function FeaturesPage() {
<div className="flex flex-col md:flex-row md:items-end justify-between gap-space-md mb-space-2xl">
<div>
<div className="flex items-center gap-space-xs mb-space-2xs">
<span className="font-code-mono text-code-mono text-primary">04 // OBD-II &amp; ECU Engine Diagnostics</span>
<span className="font-code-mono text-code-mono text-primary">04 // Phone Sensor Telemetry</span>
<span className="h-1.5 w-1.5 rounded-full bg-primary-container"></span>
</div>
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
Direct CAN-bus &amp; ECU Health Stream
Phone-Based Ride Telemetry
</h2>
</div>
<p className="font-body-md text-body-md text-on-surface-variant max-w-md">
Zero splicing or invasive wire taps. Connect MotorSignal CAN-Link directly into your bike's factory diagnostic port to harvest microsecond factory telemetry.
Use your phone's supported sensors to record ride data. OBD-II, CAN-bus, and motorcycle ECU diagnostics are not currently available.
</p>
</div>
{/* Engine Diagnostic Sensor Matrix (6 Cards) */}
<p className="font-body-md text-body-md text-on-surface-variant bg-surface-container-low p-space-lg rounded-xl">
OBD-II, CAN-bus, and motorcycle ECU diagnostics are not currently available in MotorSignal. Ride recording uses supported phone sensors.
</p>
{/* Unavailable hardware concepts are intentionally not presented as product capabilities. */}
<div hidden>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-md mb-space-2xl">
{/* Sensor 1: Coolant Temp */}
<div className="bg-surface-container-low p-space-lg rounded-xl shadow-sm hover:shadow-md transition-all">
@ -677,6 +669,7 @@ export default function FeaturesPage() {
</div>
</div>
</div>
</div>
</section>
{/* SECTION 5: CLOUD SYNC & REPLAY */}
<section className="w-full bg-surface-container-lowest py-space-4xl relative" id="cloud-replay">
@ -772,17 +765,17 @@ export default function FeaturesPage() {
Ready for Track &amp; Canyon Roads
</div>
<h2 className="font-headline-xl text-headline-xl text-on-surface max-w-2xl mb-space-md tracking-tight">
Equip your machine with pro-grade telemetry today.
Start recording rides with your phone.
</h2>
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-xl mb-space-xl">
Start your 30-day all-access trial on iOS &amp; Android, or order the plug-and-play OBD-II hardware link with instant track delivery.
Explore the ride recording and rider community features available in MotorSignal today.
</p>
<div className="flex flex-col sm:flex-row items-center gap-space-md w-full justify-center max-w-md">
<a className="w-full sm:w-auto inline-flex items-center justify-center px-space-xl py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm hover:shadow-[0_0_24px_rgba(0,242,254,0.45)] transition-all" data-path="create-account" href="/account">
Start 30-Day Free Trial
</a>
<a className="w-full sm:w-auto inline-flex items-center justify-center px-space-xl py-3.5 rounded-xl bg-surface-container-high hover:bg-surface-bright text-on-surface font-headline-sm text-headline-sm transition-all shadow-sm" data-path="hardware-and-app" href="/account">
Order OBD-II Hardware Link
Explore MotorSignal
</a>
</div>
<div className="mt-space-lg flex items-center gap-space-lg font-code-mono text-code-mono text-outline">

View File

@ -13,7 +13,7 @@ export default function PricingPage() {
return (
<div className="flagship-page text-on-surface bg-surface min-h-screen">
<div className="flex flex-col w-full">
<div hidden>
{/* Subtle Ambient Glow Orbs */}
<div className="relative w-full overflow-hidden">
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[720px] h-[380px] bg-primary-container/10 blur-[130px] pointer-events-none rounded-full"></div>
@ -507,6 +507,12 @@ export default function PricingPage() {
</div>
</section>
</div>
<section className="max-w-container-max mx-auto px-gutter-desktop py-space-4xl" aria-labelledby="pricing-unavailable-title">
<div className="max-w-3xl mx-auto rounded-2xl bg-surface-container-low p-space-2xl text-center">
<h1 id="pricing-unavailable-title" className="font-headline-xl text-headline-xl text-on-surface mb-space-md">Subscription plans are not available yet</h1>
<p className="font-body-lg text-body-lg text-on-surface-variant">MotorSignal does not currently offer web checkout or sell OBD-II or add-on hardware. You can use the app's available phone-based ride recording and community features.</p>
</div>
</section>
</div>
</div>

View File

@ -11,37 +11,49 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
const [password, setPassword] = useState('');
const [deviceName, setDeviceName] = useState('Web browser');
const [message, setMessage] = useState('');
const [googleCanceled, setGoogleCanceled] = useState(false);
const [busy, setBusy] = useState(false);
const [googleBusy, setGoogleBusy] = useState(false);
async function handleGoogleSignIn() {
setGoogleBusy(true);
setMessage('');
setGoogleCanceled(false);
try {
const credential = await signInWithPopup(firebaseAuth, googleProvider);
const idToken = await credential.user.getIdToken();
// Exchange firebase idToken with backend to set server session & ops cookies
let response: Response | undefined;
try {
await fetch('/api/auth/firebase-login', {
response = await fetch('/api/auth/firebase-login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
} catch {
// Fallback to legacy link if available
try {
await fetch('/api/account/firebase-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
} catch {
// Backend link optional; Firebase auth is sufficient for client cloud data
const legacyResponse = await fetch('/api/account/firebase-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
if (!legacyResponse.ok) {
throw new Error('Unable to establish a secure session. Please try again.');
}
}
if (response && !response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail ?? 'Unable to establish a secure session. Please try again.');
}
const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account';
window.location.assign(destination);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
if (code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') {
setGoogleCanceled(true);
setMessage('Google sign-in was canceled. You can try again.');
return;
}
console.error('Google Sign-In Error:', error);
setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.');
} finally {
@ -53,6 +65,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
event.preventDefault();
setBusy(true);
setMessage('');
setGoogleCanceled(false);
try {
const response = await fetch(`/api/auth/${mode}`, {
method: 'POST',
@ -161,7 +174,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
)}
{message && (
<div className="p-3 rounded-xl bg-error-container/20 border border-error/30 text-error text-xs font-mono" role="alert">
<div className={`p-3 rounded-xl border text-xs font-mono ${googleCanceled ? 'bg-surface-container-low border-outline-variant/40 text-on-surface-variant' : 'bg-error-container/20 border-error/30 text-error'}`} role={googleCanceled ? 'status' : 'alert'}>
{message}
</div>
)}

View File

@ -0,0 +1,336 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
type ChatMessage,
subscribeClubChat,
sendClubChatMessage,
FIXTURE_CHAT_MESSAGES,
} from '../../lib/club-community';
import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
interface FounderChatProps {
context: FounderClubContext;
}
type ChannelId = 'general' | 'officers' | 'ride-intel';
interface ChannelInfo {
id: ChannelId;
name: string;
icon: string;
description: string;
requiresOfficer: boolean;
}
const CHANNELS: ChannelInfo[] = [
{
id: 'general',
name: 'general-squad',
icon: 'chat',
description: 'General discussions, weekend meetups, and squad banter for all club members.',
requiresOfficer: false,
},
{
id: 'officers',
name: 'officers-lounge',
icon: 'lock',
description: 'Private coordination channel restricted to Club Founder and Admins.',
requiresOfficer: true,
},
{
id: 'ride-intel',
name: 'road-intel',
icon: 'radar',
description: 'Live pavement scouting, weather warnings, radar alerts, and road hazard reports.',
requiresOfficer: false,
},
];
export function FounderChat({ context }: FounderChatProps) {
const { club, permissions, currentUser, isDemoFixture } = context;
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
useEffect(() => {
setError(null);
let unsubscribe: () => void = () => {};
if (isDemoFixture) {
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId })));
} else {
unsubscribe = subscribeClubChat(
club.id,
activeChannelId,
(newMsgs) => {
setMessages(newMsgs);
},
(err) => {
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
}
);
}
return () => {
unsubscribe();
};
}, [club.id, activeChannelId, isDemoFixture]);
// Auto-scroll on new message
useEffect(() => {
if (typeof messagesEndRef.current?.scrollIntoView === 'function') {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
async function handleSendMessage(e?: React.FormEvent) {
if (e) e.preventDefault();
const clean = inputText.trim();
if (!clean || isSending || isRestrictedChannel) return;
setIsSending(true);
setError(null);
try {
if (isDemoFixture) {
const optimisticMsg: ChatMessage = {
id: `demo-msg-${Date.now()}`,
clubId: club.id,
channelId: activeChannelId,
authorUid: currentUser.uid,
authorDisplayName: 'Club Founder',
authorHandle: '@founder',
authorDisplayRank: currentUser.role === 'owner' ? 'President' : 'Admin',
authorRole: currentUser.role,
content: clean,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimisticMsg]);
} else {
await sendClubChatMessage(club.id, activeChannelId, clean);
}
setInputText('');
} catch (err) {
setError(sanitizePortalError(err, 'Failed to send message.'));
} finally {
setIsSending(false);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}
return (
<div className="w-full space-y-6">
{/* Header Card */}
<div className="glass-card p-6 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-brand-border/40">
<div>
<div className="flex items-center gap-3">
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Squad Live Chat
</h1>
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Live Connected
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions.
</p>
</div>
{/* Channel quick tabs */}
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-surface-container/60 border border-brand-border/30 shrink-0">
{CHANNELS.map((ch) => {
const isActive = ch.id === activeChannelId;
const isLocked = ch.requiresOfficer && !isOfficer;
return (
<button
key={ch.id}
onClick={() => setActiveChannelId(ch.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
isActive
? 'bg-brand-cyan text-brand-bg shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-surface-container'
}`}
>
<span className="material-symbols-outlined text-[15px]">
{isLocked ? 'lock' : ch.icon}
</span>
<span>#{ch.name}</span>
</button>
);
})}
</div>
</div>
{error && (
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">error</span>
{error}
</span>
<button onClick={() => setError(null)} className="hover:text-white transition">
✕
</button>
</div>
)}
{/* Chat Room Area */}
<div className="glass-card rounded-2xl border border-brand-border/40 overflow-hidden flex flex-col h-[600px]">
{/* Channel Banner */}
<div className="p-4 bg-surface-container/60 border-b border-brand-border/30 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="material-symbols-outlined text-brand-cyan text-[20px]">
{activeChannel.icon}
</span>
<div>
<span className="font-heading text-sm font-bold text-white">
#{activeChannel.name}
</span>
<p className="text-[11px] text-slate-400 font-body">
{activeChannel.description}
</p>
</div>
</div>
<div className="text-[11px] font-mono text-slate-500">
{messages.length} messages
</div>
</div>
{/* Message Stream */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isRestrictedChannel ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 space-y-3">
<span className="material-symbols-outlined text-4xl text-amber-500">lock</span>
<h3 className="font-heading text-base font-bold text-white">
Officers Lounge Restricted
</h3>
<p className="font-body text-xs text-slate-400 max-w-sm">
This channel is strictly reserved for club Founders and Admins. Switch to <strong className="text-brand-cyan">#general-squad</strong> or <strong className="text-brand-cyan">#road-intel</strong> to chat with the club.
</p>
</div>
) : messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 space-y-2">
<span className="material-symbols-outlined text-3xl text-slate-600">chat_bubble_outline</span>
<p className="font-body text-xs text-slate-500">
No messages yet in #{activeChannel.name}. Start the conversation!
</p>
</div>
) : (
messages.map((msg) => {
const isMe = msg.authorUid === currentUser.uid;
const isOwnerRole = msg.authorRole === 'owner';
const isAdminRole = msg.authorRole === 'admin';
return (
<div
key={msg.id}
className={`flex items-start gap-3 ${isMe ? 'flex-row-reverse' : 'flex-row'}`}
>
{/* Avatar / Monogram */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0 border ${
isOwnerRole
? 'bg-amber-500/20 text-amber-400 border-amber-500/40'
: isAdminRole
? 'bg-brand-cyan/20 text-brand-cyan border-brand-cyan/40'
: 'bg-surface-container text-slate-300 border-brand-border/40'
}`}
>
{msg.authorDisplayName.charAt(0).toUpperCase()}
</div>
{/* Message Bubble */}
<div
className={`max-w-[75%] rounded-2xl p-3 space-y-1 ${
isMe
? 'bg-brand-cyan/15 border border-brand-cyan/30 text-white'
: 'bg-surface-container/70 border border-brand-border/30 text-slate-200'
}`}
>
{/* Author Meta */}
<div className="flex items-center gap-2 text-[10px]">
<span className="font-bold text-white">
{msg.authorDisplayName}
</span>
{msg.authorHandle && (
<span className="text-slate-400 font-mono">
{msg.authorHandle}
</span>
)}
{msg.authorDisplayRank && (
<span className="font-mono px-1.5 py-0.2 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
{msg.authorDisplayRank}
</span>
)}
<span className="text-slate-500 font-mono ml-auto">
{msg.createdAt
? new Date(msg.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
: ''}
</span>
</div>
{/* Content */}
<p className="font-body text-xs leading-relaxed break-words whitespace-pre-wrap">
{msg.content}
</p>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input Box */}
<div className="p-3 bg-surface-container/80 border-t border-brand-border/30">
<form onSubmit={handleSendMessage} className="flex items-center gap-2">
<input
type="text"
disabled={isRestrictedChannel || isSending}
placeholder={
isRestrictedChannel
? 'Channel restricted to officers'
: `Message #${activeChannel.name}...`
}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={1000}
className="flex-1 bg-surface-container border border-brand-border/40 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60 disabled:opacity-50"
/>
<button
type="submit"
disabled={!inputText.trim() || isSending || isRestrictedChannel}
className="px-4 py-2.5 rounded-xl bg-brand-cyan text-brand-bg font-bold text-xs hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 disabled:opacity-40 flex items-center gap-1.5 shrink-0"
>
<span className="material-symbols-outlined text-[16px]">send</span>
<span className="hidden sm:inline">Send</span>
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -8,10 +8,12 @@ import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext }
import { FounderPortalShell } from './founder-portal-shell';
import { FounderMembers } from './founder-members';
import { FounderWall } from './founder-wall';
import { FounderRoutes } from './founder-routes';
import { FounderChat } from './founder-chat';
import { OverviewSkeleton } from './skeleton-state';
import { SafeErrorState } from './safe-error-state';
export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' }) {
export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' | 'routes' | 'chat' }) {
const router = useRouter();
const searchParams = useSearchParams();
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
@ -55,7 +57,9 @@ export function FounderCommunityPage({ clubId, section }: { clubId: string; sect
if (section === 'wall') document.getElementById('wall-post-content')?.focus();
else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`);
}}>
{section === 'members' ? <FounderMembers key={context.club.id + context.currentUser.uid} context={context} />
: <FounderWall key={context.club.id + context.currentUser.uid} context={context} />}
{section === 'members' && <FounderMembers key={context.club.id + context.currentUser.uid} context={context} />}
{section === 'wall' && <FounderWall key={context.club.id + context.currentUser.uid} context={context} />}
{section === 'routes' && <FounderRoutes key={context.club.id + context.currentUser.uid} context={context} />}
{section === 'chat' && <FounderChat key={context.club.id + context.currentUser.uid} context={context} />}
</FounderPortalShell>;
}

View File

@ -0,0 +1,597 @@
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import Link from 'next/link';
import {
type ClubRoute,
fetchClubRoutes,
createClubRoute,
deleteClubRoute,
} from '../../lib/club-community';
import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
interface FounderRoutesProps {
context: FounderClubContext;
}
export function FounderRoutes({ context }: FounderRoutesProps) {
const { club, permissions, currentUser, isDemoFixture } = context;
const [routes, setRoutes] = useState<ClubRoute[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [curvatureFilter, setCurvatureFilter] = useState<string>('all');
const [surfaceFilter, setSurfaceFilter] = useState<string>('all');
// Modal states
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [routeToDelete, setRouteToDelete] = useState<ClubRoute | null>(null);
// New route form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [distanceMiles, setDistanceMiles] = useState('35.0');
const [estimatedMinutes, setEstimatedMinutes] = useState('45');
const [elevationGainFt, setElevationGainFt] = useState('1500');
const [curvatureRating, setCurvatureRating] = useState<'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'>('High Twisties');
const [surfaceType, setSurfaceType] = useState<'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'>('Paved Asphalt');
const [startLocation, setStartLocation] = useState('');
const [endLocation, setEndLocation] = useState('');
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
async function loadRoutes() {
setLoading(true);
setError(null);
try {
const data = await fetchClubRoutes(club.id, { allowDemoPreview: isDemoFixture });
if (active) setRoutes(data);
} catch (err) {
if (active) setError(sanitizePortalError(err, 'Failed to load club routes.'));
} finally {
if (active) setLoading(false);
}
}
loadRoutes();
return () => {
active = false;
};
}, [club.id, isDemoFixture]);
const filteredRoutes = useMemo(() => {
return routes.filter((r) => {
const matchesSearch =
!searchQuery.trim() ||
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.curatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.startLocation.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.endLocation.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCurvature =
curvatureFilter === 'all' || r.curvatureRating === curvatureFilter;
const matchesSurface =
surfaceFilter === 'all' || r.surfaceType === surfaceFilter;
return matchesSearch && matchesCurvature && matchesSurface;
});
}, [routes, searchQuery, curvatureFilter, surfaceFilter]);
async function handleAddRoute(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !startLocation.trim() || !endLocation.trim()) {
setModalError('Title, start location, and end location are required.');
return;
}
setIsSubmitting(true);
setModalError(null);
try {
if (isDemoFixture) {
const newDemoRoute: ClubRoute = {
id: `demo-route-${Date.now()}`,
clubId: club.id,
title: title.trim(),
description: description.trim(),
distanceMiles: parseFloat(distanceMiles) || 20,
estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
elevationGainFt: parseInt(elevationGainFt, 10) || 500,
curvatureRating,
surfaceType,
startLocation: startLocation.trim(),
endLocation: endLocation.trim(),
curatorUid: currentUser.uid,
curatorName: 'Club Founder',
createdAt: new Date().toISOString(),
};
setRoutes([newDemoRoute, ...routes]);
} else {
await createClubRoute(club.id, {
title: title.trim(),
description: description.trim(),
distanceMiles: parseFloat(distanceMiles) || 20,
estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
elevationGainFt: parseInt(elevationGainFt, 10) || 500,
curvatureRating,
surfaceType,
startLocation: startLocation.trim(),
endLocation: endLocation.trim(),
});
const updated = await fetchClubRoutes(club.id);
setRoutes(updated);
}
setIsAddModalOpen(false);
setTitle('');
setDescription('');
setStartLocation('');
setEndLocation('');
} catch (err) {
setModalError(sanitizePortalError(err, 'Failed to save route.'));
} finally {
setIsSubmitting(false);
}
}
async function handleDeleteRoute(routeId: string) {
try {
if (isDemoFixture) {
setRoutes((prev) => prev.filter((r) => r.id !== routeId));
} else {
await deleteClubRoute(club.id, routeId);
setRoutes((prev) => prev.filter((r) => r.id !== routeId));
}
setRouteToDelete(null);
} catch (err) {
setError(sanitizePortalError(err, 'Failed to delete route.'));
}
}
function downloadGpx(route: ClubRoute) {
const gpxContent = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="MotorSignal Club Platform" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${route.title}</name>
<desc>${route.description}</desc>
</metadata>
<rte>
<name>${route.title}</name>
<rtept lat="34.526" lon="-83.984"><name>${route.startLocation}</name></rtept>
<rtept lat="34.685" lon="-83.916"><name>${route.endLocation}</name></rtept>
</rte>
</gpx>`;
const blob = new Blob([gpxContent], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${route.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.gpx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const canCurate = permissions.canPlanRides || permissions.canManageSettings;
return (
<div className="w-full space-y-6">
{/* Top Header Card */}
<div className="glass-card p-6 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-brand-border/40">
<div>
<div className="flex items-center gap-3">
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Curated Club Routes
</h1>
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-brand-cyan/15 text-brand-cyan border border-brand-cyan/30 font-semibold">
{routes.length} Available
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
Curate twisty runs, elevation passes, and scenic loops for club group rides. Export GPX files directly for offline GPS units and cockpit navigation.
</p>
</div>
{canCurate && (
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-brand-cyan text-brand-bg font-body text-xs font-bold hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 shrink-0"
>
<span className="material-symbols-outlined text-[18px]">add_road</span>
<span>Curate Route</span>
</button>
)}
</div>
{error && (
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">error</span>
{error}
</span>
<button
onClick={() => setError(null)}
className="hover:text-white transition"
>
✕
</button>
</div>
)}
{/* Filter and Search Controls */}
<div className="glass-card p-4 rounded-xl border border-brand-border/30 flex flex-col md:flex-row items-stretch md:items-center gap-3 justify-between">
<div className="relative flex-1 min-w-[200px]">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[18px]">
search
</span>
<input
type="text"
placeholder="Search routes by name, waypoint, or curator..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-surface-container/60 border border-brand-border/40 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Curvature Filter */}
<select
value={curvatureFilter}
onChange={(e) => setCurvatureFilter(e.target.value)}
aria-label="Filter by Curvature"
className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
>
<option value="all">All Curvatures</option>
<option value="High Twisties">High Twisties</option>
<option value="Moderate">Moderate</option>
<option value="Scenic Cruise">Scenic Cruise</option>
<option value="Track Pace">Track Pace</option>
</select>
{/* Surface Filter */}
<select
value={surfaceFilter}
onChange={(e) => setSurfaceFilter(e.target.value)}
aria-label="Filter by Surface"
className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
>
<option value="all">All Surfaces</option>
<option value="Paved Asphalt">Paved Asphalt</option>
<option value="Mountain Pass">Mountain Pass</option>
<option value="Mixed Surface">Mixed Surface</option>
</select>
</div>
</div>
{/* Routes List Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((n) => (
<div key={n} className="glass-card p-5 rounded-2xl border border-brand-border/20 animate-pulse space-y-4">
<div className="h-6 w-3/4 bg-surface-container rounded" />
<div className="h-4 w-full bg-surface-container/60 rounded" />
<div className="h-16 w-full bg-surface-container/40 rounded-xl" />
</div>
))}
</div>
) : filteredRoutes.length === 0 ? (
<div className="glass-card p-12 rounded-2xl border border-brand-border/30 text-center space-y-3">
<span className="material-symbols-outlined text-4xl text-slate-500">route</span>
<h3 className="font-heading text-lg font-bold text-white">No Routes Found</h3>
<p className="font-body text-xs text-slate-400 max-w-sm mx-auto">
{searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all'
? 'No routes match your search or filter criteria. Try resetting your filters.'
: 'No club routes have been curated yet. Be the first to add a route for upcoming club rides!'}
</p>
{(searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all') && (
<button
onClick={() => {
setSearchQuery('');
setCurvatureFilter('all');
setSurfaceFilter('all');
}}
className="text-xs font-semibold text-brand-cyan hover:underline mt-2 inline-block"
>
Reset Filters
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filteredRoutes.map((route) => {
const isHighCurvature = route.curvatureRating === 'High Twisties';
const isModerate = route.curvatureRating === 'Moderate';
return (
<div
key={route.id}
className="glass-card rounded-2xl p-5 border border-brand-border/40 hover:border-brand-cyan/40 transition flex flex-col justify-between space-y-4"
>
<div className="space-y-3">
{/* Card Header & Badge */}
<div className="flex items-start justify-between gap-2">
<h3 className="font-heading text-base font-bold text-white leading-snug">
{route.title}
</h3>
<span
className={`shrink-0 font-mono text-[10px] px-2 py-0.5 rounded font-semibold border ${
isHighCurvature
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: isModerate
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/30'
: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
}`}
>
{route.curvatureRating}
</span>
</div>
<p className="font-body text-xs text-slate-300 line-clamp-2">
{route.description}
</p>
{/* Waypoint Strip */}
<div className="flex items-center gap-1.5 text-[11px] font-mono text-slate-400 bg-surface-container/60 px-3 py-1.5 rounded-lg border border-brand-border/20 truncate">
<span className="material-symbols-outlined text-[14px] text-brand-cyan shrink-0">
trip_origin
</span>
<span className="truncate">{route.startLocation}</span>
<span className="text-slate-600 shrink-0">➔</span>
<span className="truncate">{route.endLocation}</span>
</div>
{/* Metrics Bar */}
<div className="grid grid-cols-3 gap-2 py-2 border-y border-brand-border/20 text-center font-mono">
<div>
<div className="text-[10px] text-slate-500 uppercase">Distance</div>
<div className="text-xs font-bold text-white">{route.distanceMiles} mi</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase">Est. Time</div>
<div className="text-xs font-bold text-white">{route.estimatedMinutes} min</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase">Climb</div>
<div className="text-xs font-bold text-brand-cyan">+{route.elevationGainFt} ft</div>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[13px] text-slate-500">terrain</span>
<span>{route.surfaceType}</span>
</span>
<span className="text-slate-500">By {route.curatorName}</span>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 flex items-center gap-2 justify-between border-t border-brand-border/20">
<button
onClick={() => downloadGpx(route)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container/80 hover:bg-surface-container border border-brand-border/40 text-slate-300 font-body text-xs font-semibold transition"
title="Export GPX waypoint file"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">download</span>
<span>GPX</span>
</button>
<div className="flex items-center gap-1.5">
<Link
href={`/clubs/${club.slug || club.id}/manage/rides${isDemoFixture ? '?preview=demo' : ''}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-cyan/15 hover:bg-brand-cyan/25 border border-brand-cyan/40 text-brand-cyan font-body text-xs font-semibold transition"
>
<span className="material-symbols-outlined text-[15px]">add_road</span>
<span>Stage Ride</span>
</Link>
{canCurate && (
<button
onClick={() => setRouteToDelete(route)}
className="p-1.5 rounded-lg hover:bg-brand-crimson/10 text-slate-500 hover:text-brand-crimson border border-transparent hover:border-brand-crimson/30 transition"
title="Delete Route"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Curate Route Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div
ref={modalRef}
className="glass-card w-full max-w-lg rounded-2xl p-6 border border-brand-border/50 shadow-2xl space-y-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between border-b border-brand-border/30 pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-[22px]">add_road</span>
<h2 className="font-heading text-lg font-bold text-white">Curate Club Route</h2>
</div>
<button
onClick={() => setIsAddModalOpen(false)}
className="text-slate-400 hover:text-white transition"
>
✕
</button>
</div>
{modalError && (
<div className="p-3 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs">
{modalError}
</div>
)}
<form onSubmit={handleAddRoute} className="space-y-4 text-xs font-body">
<div>
<label className="block text-slate-300 font-semibold mb-1">Route Title *</label>
<input
type="text"
required
placeholder="e.g. Wolfpen Gap Apex Loop"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description / Road Notes</label>
<textarea
rows={3}
placeholder="Notes on asphalt condition, high-lean apexes, gravel washouts, fuel stops..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start Location *</label>
<input
type="text"
required
placeholder="e.g. Shell Outpost, Dahlonega"
value={startLocation}
onChange={(e) => setStartLocation(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End Location *</label>
<input
type="text"
required
placeholder="e.g. Vogel State Park"
value={endLocation}
onChange={(e) => setEndLocation(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Distance (mi)</label>
<input
type="number"
step="0.1"
min="1"
value={distanceMiles}
onChange={(e) => setDistanceMiles(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Est. Duration (min)</label>
<input
type="number"
min="5"
value={estimatedMinutes}
onChange={(e) => setEstimatedMinutes(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Elevation (+ft)</label>
<input
type="number"
min="0"
value={elevationGainFt}
onChange={(e) => setElevationGainFt(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Curvature Rating</label>
<select
value={curvatureRating}
onChange={(e) => setCurvatureRating(e.target.value as any)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
>
<option value="High Twisties">High Twisties</option>
<option value="Moderate">Moderate</option>
<option value="Scenic Cruise">Scenic Cruise</option>
<option value="Track Pace">Track Pace</option>
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Surface Type</label>
<select
value={surfaceType}
onChange={(e) => setSurfaceType(e.target.value as any)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
>
<option value="Paved Asphalt">Paved Asphalt</option>
<option value="Mountain Pass">Mountain Pass</option>
<option value="Mixed Surface">Mixed Surface</option>
</select>
</div>
</div>
<div className="pt-3 flex items-center justify-end gap-3 border-t border-brand-border/30">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white transition"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-5 py-2 rounded-xl bg-brand-cyan text-brand-bg font-bold hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 disabled:opacity-50"
>
{isSubmitting ? 'Saving Route...' : 'Save & Publish Route'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{routeToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="glass-card w-full max-w-md rounded-2xl p-6 border border-brand-crimson/40 shadow-2xl space-y-4">
<div className="flex items-center gap-3 text-brand-crimson">
<span className="material-symbols-outlined text-[24px]">warning</span>
<h3 className="font-heading text-base font-bold text-white">Delete Club Route?</h3>
</div>
<p className="font-body text-xs text-slate-300">
Are you sure you want to remove <strong className="text-white">{routeToDelete.title}</strong> from the club catalog? This action cannot be undone.
</p>
<div className="flex items-center justify-end gap-3 pt-2">
<button
onClick={() => setRouteToDelete(null)}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white text-xs transition"
>
Cancel
</button>
<button
onClick={() => handleDeleteRoute(routeToDelete.id)}
className="px-4 py-2 rounded-xl bg-brand-crimson text-white font-bold text-xs hover:bg-brand-crimson/90 transition shadow-lg shadow-brand-crimson/20"
>
Confirm Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -62,9 +62,9 @@ export function FounderSidebar({
badgeType: 'secondary',
disabled: false,
},
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'routes', label: 'Routes', icon: 'map', disabled: false },
{ id: 'wall', label: 'Wall', icon: 'forum', disabled: false },
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'chat', label: 'Chat', icon: 'chat', disabled: false },
{ id: 'stats', label: 'Stats', icon: 'query_stats', disabled: false },
];
@ -77,7 +77,9 @@ export function FounderSidebar({
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'members') return `/clubs/${clubParam}/manage/members${queryParam}`;
if (id === 'routes') return `/clubs/${clubParam}/manage/routes${queryParam}`;
if (id === 'wall') return `/clubs/${clubParam}/manage/wall${queryParam}`;
if (id === 'chat') return `/clubs/${clubParam}/manage/chat${queryParam}`;
if (id === 'stats') return `/clubs/${clubParam}/manage/stats${queryParam}`;
if (id === 'moderation') return `/clubs/${clubParam}/manage/moderation${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;

View File

@ -121,10 +121,12 @@ export function FounderTopBar({
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
</>
)}
{(activeNav === 'members' || activeNav === 'wall') && (
{(activeNav === 'members' || activeNav === 'wall' || activeNav === 'routes' || activeNav === 'chat') && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">{activeNav === 'members' ? 'Members' : 'Wall'}</span>
<span className="text-brand-cyan font-semibold truncate">
{activeNav === 'members' ? 'Members' : activeNav === 'wall' ? 'Wall' : activeNav === 'routes' ? 'Routes' : 'Chat'}
</span>
</>
)}
{activeNav === 'applications' && (

View File

@ -1,5 +1,5 @@
import {
collection, deleteDoc, doc, getDoc, limit, onSnapshot, orderBy, query,
collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query,
runTransaction, serverTimestamp, setDoc, updateDoc,
type DocumentData, type Transaction,
} from 'firebase/firestore';
@ -177,3 +177,317 @@ export async function deleteWallPost(clubId: string, postId: string): Promise<vo
callerUid();
await deleteDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId));
}
// ============================================================================
// CLUB ROUTES MANAGEMENT
// ============================================================================
export interface ClubRoute {
id: string;
clubId: string;
title: string;
description: string;
distanceMiles: number;
estimatedMinutes: number;
elevationGainFt: number;
curvatureRating: 'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace';
surfaceType: 'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass';
startLocation: string;
endLocation: string;
gpxUrl?: string | null;
curatorUid: string;
curatorName: string;
createdAt: string;
}
export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [
{
id: 'route-blood-mountain',
clubId: 'ClubID',
title: 'Blood Mountain Gap Loop',
description: 'Technical mountain sweepers through US-19/129 with fast elevation transitions and fresh asphalt.',
distanceMiles: 48.6,
estimatedMinutes: 65,
elevationGainFt: 3120,
curvatureRating: 'High Twisties',
surfaceType: 'Paved Asphalt',
startLocation: 'Dahlonega Outpost',
endLocation: 'Vogel State Park',
gpxUrl: '/fixtures/routes/blood-mountain.gpx',
curatorUid: 'owner',
curatorName: 'Morgan Vance',
createdAt: '2026-03-15T10:00:00Z',
},
{
id: 'route-cherohala-skyway',
clubId: 'ClubID',
title: 'Cherohala High Ridge Sprint',
description: 'Sweeping national scenic byway climbing above 5,300 feet. Broad turns with optimal lean sightlines.',
distanceMiles: 52.4,
estimatedMinutes: 72,
elevationGainFt: 4200,
curvatureRating: 'Moderate',
surfaceType: 'Mountain Pass',
startLocation: 'Tellico Plains',
endLocation: 'Robbinsville Overlook',
gpxUrl: '/fixtures/routes/cherohala.gpx',
curatorUid: 'member',
curatorName: 'Taylor Cruz',
createdAt: '2026-04-02T14:30:00Z',
},
{
id: 'route-coastal-creek',
clubId: 'ClubID',
title: 'Coastal Marshes Scenic Cruise',
description: 'Relaxed scenic run through coastal causeways and oak canopies. Ideal for group pack acclimation.',
distanceMiles: 38.0,
estimatedMinutes: 50,
elevationGainFt: 180,
curvatureRating: 'Scenic Cruise',
surfaceType: 'Paved Asphalt',
startLocation: 'Savannah Riverfront',
endLocation: 'Tybee Lighthouse',
gpxUrl: '/fixtures/routes/coastal-creek.gpx',
curatorUid: 'admin',
curatorName: 'Alex Mercer',
createdAt: '2026-05-10T09:15:00Z',
},
];
export async function fetchClubRoutes(clubId: string, options?: { allowDemoPreview?: boolean }): Promise<ClubRoute[]> {
if (options?.allowDemoPreview) {
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
}
callerUid();
const routesRef = collection(firestore, 'clubs', clubId, 'routes');
const q = query(routesRef, orderBy('createdAt', 'desc'), limit(50));
const snap = await getDocs(q);
if (snap.empty) {
// Return sample starter routes if no custom routes have been uploaded
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
}
return snap.docs.map((d) => {
const data = d.data();
return {
id: d.id,
clubId,
title: String(data.title || 'Untitled Route'),
description: String(data.description || ''),
distanceMiles: typeof data.distanceMiles === 'number' ? data.distanceMiles : 0,
estimatedMinutes: typeof data.estimatedMinutes === 'number' ? data.estimatedMinutes : 0,
elevationGainFt: typeof data.elevationGainFt === 'number' ? data.elevationGainFt : 0,
curvatureRating: data.curvatureRating || 'Moderate',
surfaceType: data.surfaceType || 'Paved Asphalt',
startLocation: String(data.startLocation || 'Club Staging Point'),
endLocation: String(data.endLocation || 'Destination'),
gpxUrl: data.gpxUrl || null,
curatorUid: String(data.curatorUid || ''),
curatorName: String(data.curatorName || 'Road Captain'),
createdAt: isoDate(data.createdAt),
};
});
}
export async function createClubRoute(
clubId: string,
route: {
title: string;
description: string;
distanceMiles: number;
estimatedMinutes: number;
elevationGainFt: number;
curvatureRating: 'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace';
surfaceType: 'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass';
startLocation: string;
endLocation: string;
gpxUrl?: string | null;
}
): Promise<void> {
const author = await wallAuthor(clubId);
const title = cleanContent(route.title, 120);
const description = (route.description || '').trim().slice(0, 1000);
const startLoc = cleanContent(route.startLocation, 120);
const endLoc = cleanContent(route.endLocation, 120);
const ref = doc(collection(firestore, 'clubs', clubId, 'routes'));
await setDoc(ref, {
id: ref.id,
clubId,
title,
description,
distanceMiles: Math.max(0.1, Number(route.distanceMiles) || 1),
estimatedMinutes: Math.max(1, Number(route.estimatedMinutes) || 10),
elevationGainFt: Math.max(0, Number(route.elevationGainFt) || 0),
curvatureRating: route.curvatureRating,
surfaceType: route.surfaceType,
startLocation: startLoc,
endLocation: endLoc,
gpxUrl: route.gpxUrl || null,
curatorUid: author.authorUid,
curatorName: author.authorDisplayName,
createdAt: serverTimestamp(),
});
}
export async function deleteClubRoute(clubId: string, routeId: string): Promise<void> {
const uid = callerUid();
const [club, member] = await Promise.all([
getDoc(doc(firestore, 'clubs', clubId)),
getDoc(doc(firestore, 'clubs', clubId, 'members', uid)),
]);
if (!club.exists() || club.data().archivedAt) throw new Error('not-found');
const isOwner = club.data().ownerUid === uid;
const isAdmin = member.exists() && member.data().status === 'active' && member.data().role === 'admin';
if (!isOwner && !isAdmin) throw new Error('permission-denied');
await deleteDoc(doc(firestore, 'clubs', clubId, 'routes', routeId));
}
// ============================================================================
// CLUB CHAT MESSAGING
// ============================================================================
export interface ChatMessage {
id: string;
clubId: string;
channelId: string;
authorUid: string;
authorDisplayName: string;
authorHandle?: string | null;
authorDisplayRank?: string | null;
authorRole: ClubRole;
content: string;
createdAt: string;
}
export const FIXTURE_CHAT_MESSAGES: Record<string, ChatMessage[]> = {
general: [
{
id: 'msg-gen-01',
clubId: 'ClubID',
channelId: 'general',
authorUid: 'owner',
authorDisplayName: 'Morgan Vance',
authorHandle: '@morgan_v',
authorDisplayRank: 'President',
authorRole: 'owner',
content: 'Welcome to the club squad room! Roll call for Saturday mountain run is open in Rides.',
createdAt: '2026-06-01T12:00:00Z',
},
{
id: 'msg-gen-02',
clubId: 'ClubID',
channelId: 'general',
authorUid: 'member',
authorDisplayName: 'Taylor Cruz',
authorHandle: '@taylor_c',
authorDisplayRank: 'Road Captain',
authorRole: 'member',
content: 'I scouted the Gap yesterday — pavement is pristine and zero gravel washouts.',
createdAt: '2026-06-01T12:15:00Z',
},
],
officers: [
{
id: 'msg-off-01',
clubId: 'ClubID',
channelId: 'officers',
authorUid: 'owner',
authorDisplayName: 'Morgan Vance',
authorHandle: '@morgan_v',
authorDisplayRank: 'President',
authorRole: 'owner',
content: 'Officers: let us review the pending membership requests before Friday briefing.',
createdAt: '2026-06-01T11:00:00Z',
},
{
id: 'msg-off-02',
clubId: 'ClubID',
channelId: 'officers',
authorUid: 'admin',
authorDisplayName: 'Alex Mercer',
authorHandle: '@alex_m',
authorDisplayRank: 'Vice President',
authorRole: 'admin',
content: 'Understood. I checked both applicants; their safety profiles and ride histories look solid.',
createdAt: '2026-06-01T11:20:00Z',
},
],
'ride-intel': [
{
id: 'msg-intel-01',
clubId: 'ClubID',
channelId: 'ride-intel',
authorUid: 'member',
authorDisplayName: 'Taylor Cruz',
authorHandle: '@taylor_c',
authorDisplayRank: 'Road Captain',
authorRole: 'member',
content: 'Radar alert: morning fog near the summit clearing by 10 AM. Recommended tire pressures nominal.',
createdAt: '2026-06-01T08:30:00Z',
},
],
};
export function subscribeClubChat(
clubId: string,
channelId: string,
onMessages: (msgs: ChatMessage[]) => void,
onError: (err: unknown) => void
): () => void {
const colRef = collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages');
const q = query(colRef, orderBy('createdAt', 'asc'), limit(100));
return onSnapshot(
q,
(snap) => {
if (snap.empty) {
// Fallback to fixture messages if channel has no messages yet
const initial = FIXTURE_CHAT_MESSAGES[channelId] || [];
onMessages(initial.map((m) => ({ ...m, clubId, channelId })));
return;
}
const msgs: ChatMessage[] = snap.docs.map((d) => {
const data = d.data();
return {
id: d.id,
clubId,
channelId,
authorUid: String(data.authorUid || ''),
authorDisplayName: String(data.authorDisplayName || 'Rider'),
authorHandle: data.authorHandle || null,
authorDisplayRank: data.authorDisplayRank || null,
authorRole: (data.authorRole as ClubRole) || 'member',
content: String(data.content || ''),
createdAt: isoDate(data.createdAt),
};
});
onMessages(msgs);
},
onError
);
}
export async function sendClubChatMessage(
clubId: string,
channelId: string,
content: string
): Promise<void> {
const clean = cleanContent(content, 1000);
const author = await wallAuthor(clubId);
// Officers channel requires owner or admin role
if (channelId === 'officers' && author.authorRole === 'member') {
throw new Error('permission-denied');
}
const ref = doc(collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages'));
await setDoc(ref, {
...author,
id: ref.id,
clubId,
channelId,
content: clean,
createdAt: serverTimestamp(),
});
}

View File

@ -1,8 +1,26 @@
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
const app = getApps().length ? getApp() : initializeApp({
// Explicit local QA opt-in. A separate app/project prevents reusing a rider's
// production session, and loopback endpoints cannot send fixtures to production.
const useEmulators = process.env.NEXT_PUBLIC_FIREBASE_EMULATORS === 'true';
const qaProject = process.env.NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID;
const authEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT ?? 9099);
const firestoreEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT ?? 8088);
if (useEmulators && (!qaProject?.startsWith('demo-') ||
(typeof window !== 'undefined' && !['localhost', '127.0.0.1', '[::1]'].includes(window.location.hostname)))) {
throw new Error('Firebase QA requires a demo- project and a loopback website origin.');
}
if (useEmulators && (![authEmulatorPort, firestoreEmulatorPort].every((port) =>
Number.isInteger(port) && port >= 1 && port <= 65535))) {
throw new Error('Firebase QA emulator ports must be valid TCP ports.');
}
const existingQaApp = getApps().find((candidate) => candidate.name === 'motorsignal-local-qa');
const app = useEmulators
? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa')
: getApps().some((candidate) => candidate.name === '[DEFAULT]') ? getApp() : initializeApp({
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
authDomain: 'motoapp-d22e0.firebaseapp.com',
projectId: 'motoapp-d22e0',
@ -14,3 +32,8 @@ const app = getApps().length ? getApp() : initializeApp({
export const firebaseAuth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
export const firestore = getFirestore(app);
if (useEmulators && !existingQaApp) {
connectAuthEmulator(firebaseAuth, `http://127.0.0.1:${authEmulatorPort}`);
connectFirestoreEmulator(firestore, '127.0.0.1', firestoreEmulatorPort);
}

View File

@ -0,0 +1,11 @@
{
"firestore": {
"rules": "qa-firestore.rules"
},
"emulators": {
"auth": { "host": "127.0.0.1", "port": 9199 },
"firestore": { "host": "127.0.0.1", "port": 8188 },
"ui": { "enabled": false },
"singleProjectMode": false
}
}

View File

@ -0,0 +1,78 @@
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import { initializeApp, getApps } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
const projectId = process.env.FIREBASE_PROJECT_ID;
const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
const port = Number(process.env.QA_AUTH_BACKEND_PORT ?? 8189);
if (!projectId?.startsWith('demo-') || !authEmulatorHost?.startsWith('127.0.0.1:')) {
throw new Error('QA auth backend requires a demo project and loopback Auth emulator.');
}
const app = getApps()[0] ?? initializeApp({ projectId });
const auth = getAuth(app);
const sessions = new Set();
function json(response, status, body) {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
if (url.pathname === '/api/v1/auth/firebase/login' && request.method === 'POST') {
let payload;
try { payload = JSON.parse(await new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => { body += chunk; if (body.length > 100_000) reject(new Error('too large')); });
request.on('end', () => resolve(body));
request.on('error', reject);
})); } catch {
console.log(JSON.stringify({ endpoint: url.pathname, status: 400, reason: 'invalid-json' }));
return json(response, 400, { detail: 'Invalid request body.' });
}
try {
const decoded = await auth.verifyIdToken(payload?.id_token);
if (decoded.aud !== projectId || decoded.iss !== `https://securetoken.google.com/${projectId}`) {
throw new Error('Project claims mismatch.');
}
const accessToken = `qa-session-${randomBytes(24).toString('hex')}`;
const refreshToken = `qa-refresh-${randomBytes(24).toString('hex')}`;
sessions.add(accessToken);
sessions.add(refreshToken);
console.log(JSON.stringify({ endpoint: url.pathname, status: 200, verified: true, projectId, uidPrefix: decoded.uid.slice(0, 8) }));
return json(response, 200, { access_token: accessToken, refresh_token: refreshToken });
} catch {
console.log(JSON.stringify({ endpoint: url.pathname, status: 401, verified: false, projectId }));
return json(response, 401, { detail: 'Firebase token verification failed.' });
}
}
if (url.pathname === '/api/v1/auth/login' && request.method === 'POST') {
console.log(JSON.stringify({ endpoint: url.pathname, status: 401, reason: 'email-auth-not-configured-in-local-fixture' }));
return json(response, 401, { detail: 'Email authentication is not configured in this local QA fixture.' });
}
if (url.pathname === '/api/v1/auth/sessions' && request.method === 'GET') {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
const ok = Boolean(token && sessions.has(token));
console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 200 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
return ok ? json(response, 200, [{ id: 'qa-current-session', device_name: 'Isolated local browser', current: true, created_at: new Date().toISOString() }])
: json(response, 401, { detail: 'Authentication required.' });
}
if (url.pathname.startsWith('/api/v1/auth/') && ['POST', 'DELETE'].includes(request.method ?? '')) {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
const ok = Boolean(token && sessions.has(token));
console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 204 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
response.writeHead(ok ? 204 : 401);
return response.end();
}
console.log(JSON.stringify({ endpoint: url.pathname, status: 404 }));
return json(response, 404, { detail: 'Local QA endpoint not found.' });
});
server.listen(port, '127.0.0.1', () => console.log(JSON.stringify({ listening: `127.0.0.1:${port}`, projectId, authEmulatorHost })));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AuthForm } from '../components/auth-form';
import { signInWithPopup } from 'firebase/auth';
vi.mock('../lib/firebase-client', () => ({
firebaseAuth: {},
googleProvider: {},
}));
vi.mock('firebase/auth', () => ({
signInWithPopup: vi.fn(),
}));
describe('AuthForm Google session exchange', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(signInWithPopup).mockResolvedValue({
user: { getIdToken: async () => 'emulator-id-token' },
} as Awaited<ReturnType<typeof signInWithPopup>>);
});
afterEach(() => {
cleanup();
global.fetch = originalFetch;
});
it.each(['auth/popup-closed-by-user', 'auth/cancelled-popup-request'])('handles %s as cancellation without a console error or session exchange', async (code) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
global.fetch = vi.fn();
vi.mocked(signInWithPopup).mockRejectedValueOnce(Object.assign(new Error(`Firebase: Error (${code}).`), { code }));
render(<AuthForm mode="login" />);
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
await waitFor(() => {
expect(screen.getByRole('status').textContent).toBe('Google sign-in was canceled. You can try again.');
});
expect(consoleError).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
expect((screen.getByRole('button', { name: /sign in with google/i }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole('button', { name: 'Sign In' }) as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('alert')).toBeNull();
});
it('still reports genuine Google sign-in failures', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
global.fetch = vi.fn();
vi.mocked(signInWithPopup).mockRejectedValueOnce(Object.assign(new Error('Google sign-in is unavailable.'), { code: 'auth/network-request-failed' }));
render(<AuthForm mode="login" />);
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
await waitFor(() => expect(screen.getByRole('alert').textContent).toBe('Google sign-in is unavailable.'));
expect(consoleError).toHaveBeenCalledOnce();
expect(global.fetch).not.toHaveBeenCalled();
});
it('shows session rejection when server exchange returns 401', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ detail: 'Session exchange rejected.' }),
});
render(<AuthForm mode="login" />);
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Session exchange rejected.');
});
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
initializeApp: vi.fn((_options: unknown, name = '[DEFAULT]') => ({ name })),
getApps: vi.fn((): Array<{ name: string }> => []),
getApp: vi.fn(),
connectAuth: vi.fn(),
connectFirestore: vi.fn(),
}));
vi.mock('firebase/app', () => mocks);
vi.mock('firebase/auth', () => ({ getAuth: vi.fn(() => ({})), GoogleAuthProvider: class {}, connectAuthEmulator: mocks.connectAuth }));
vi.mock('firebase/firestore', () => ({ getFirestore: vi.fn(() => ({})), connectFirestoreEmulator: mocks.connectFirestore }));
describe('isolated Firebase QA client', () => {
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.getApps.mockReturnValue([]); });
afterEach(() => vi.unstubAllEnvs());
it('keeps production defaults without explicit opt-in', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'false');
await import('../lib/firebase-client');
expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'motoapp-d22e0' }));
expect(mocks.connectAuth).not.toHaveBeenCalled();
});
it('rejects a production project before initializing Firebase', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'motoapp-d22e0');
await expect(import('../lib/firebase-client')).rejects.toThrow('demo- project');
expect(mocks.initializeApp).not.toHaveBeenCalled();
});
it('connects an isolated named demo app to both loopback emulators', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-qa');
await import('../lib/firebase-client');
expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'demo-motorsignal-qa' }), 'motorsignal-local-qa');
expect(mocks.connectAuth).toHaveBeenCalledWith(expect.anything(), 'http://127.0.0.1:9099');
expect(mocks.connectFirestore).toHaveBeenCalledWith(expect.anything(), '127.0.0.1', 8088);
});
it('supports dedicated QA emulator ports without changing production defaults', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-web-qa');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT', '9199');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT', '8188');
await import('../lib/firebase-client');
expect(mocks.connectAuth).toHaveBeenCalledWith(expect.anything(), 'http://127.0.0.1:9199');
expect(mocks.connectFirestore).toHaveBeenCalledWith(expect.anything(), '127.0.0.1', 8188);
});
it('rejects invalid dedicated QA emulator ports', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-web-qa');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT', '99999');
await expect(import('../lib/firebase-client')).rejects.toThrow('valid TCP ports');
expect(mocks.initializeApp).not.toHaveBeenCalled();
});
it('reuses an already connected QA app during module reload', async () => {
vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-qa');
mocks.getApps.mockReturnValue([{ name: 'motorsignal-local-qa' }]);
await import('../lib/firebase-client');
expect(mocks.initializeApp).not.toHaveBeenCalled();
expect(mocks.connectFirestore).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,361 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FounderRoutes } from '../components/founder-portal/founder-routes';
import { FounderChat } from '../components/founder-portal/founder-chat';
import { FounderCommunityPage } from '../components/founder-portal/founder-community-page';
import { FounderSidebar } from '../components/founder-portal/founder-sidebar';
import type { FounderClubContext } from '../lib/club-data';
import type { ClubRoute, ChatMessage } from '../lib/club-community';
const mocks = vi.hoisted(() => ({
fetchRoutes: vi.fn(),
createRoute: vi.fn(),
deleteRoute: vi.fn(),
subscribeChat: vi.fn(),
sendChat: vi.fn(),
context: vi.fn(),
auth: vi.fn(),
push: vi.fn(),
}));
vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} }));
vi.mock('firebase/auth', () => ({
onAuthStateChanged: (...args: unknown[]) => mocks.auth(...args),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.push }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('../components/founder-portal/founder-portal-shell', () => ({
FounderPortalShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../lib/club-data', () => ({
fetchFounderClubContext: (...args: unknown[]) => mocks.context(...args),
sanitizePortalError: (_err: unknown, fallback = 'Club error') => fallback,
}));
vi.mock('../lib/club-community', () => ({
fetchClubRoutes: (...args: unknown[]) => mocks.fetchRoutes(...args),
createClubRoute: (...args: unknown[]) => mocks.createRoute(...args),
deleteClubRoute: (...args: unknown[]) => mocks.deleteRoute(...args),
subscribeClubChat: (...args: unknown[]) => mocks.subscribeChat(...args),
sendClubChatMessage: (...args: unknown[]) => mocks.sendChat(...args),
FIXTURE_CHAT_MESSAGES: {
general: [
{
id: 'msg-1',
clubId: 'ClubID',
channelId: 'general',
authorUid: 'owner',
authorDisplayName: 'Morgan Vance',
authorHandle: '@morgan_v',
authorDisplayRank: 'President',
authorRole: 'owner',
content: 'Welcome to squad chat!',
createdAt: '2026-06-01T12:00:00Z',
},
],
officers: [
{
id: 'msg-off-1',
clubId: 'ClubID',
channelId: 'officers',
authorUid: 'owner',
authorDisplayName: 'Morgan Vance',
authorHandle: '@morgan_v',
authorDisplayRank: 'President',
authorRole: 'owner',
content: 'Officers briefing notes.',
createdAt: '2026-06-01T11:00:00Z',
},
],
'ride-intel': [],
},
}));
const mockContext: FounderClubContext = {
club: {
id: 'ClubID',
slug: 'atlantic-moto',
name: 'Atlantic Moto Club',
ownerUid: 'owner',
description: '',
visibility: 'private',
admissionPolicy: 'request',
memberCount: 5,
createdAt: '',
updatedAt: '',
},
currentUser: { uid: 'owner', role: 'owner' },
ownerProfile: null,
isDemoFixture: false,
permissions: {
isAuthenticated: true,
role: 'owner',
canManageSettings: true,
canInviteMembers: true,
canApproveApplications: true,
canPlanRides: true,
canTransferOwnership: true,
},
};
const sampleRoutes: ClubRoute[] = [
{
id: 'r1',
clubId: 'ClubID',
title: 'Blood Mountain Gap Loop',
description: 'Technical mountain sweepers through US-19/129 with fast transitions.',
distanceMiles: 48.6,
estimatedMinutes: 65,
elevationGainFt: 3120,
curvatureRating: 'High Twisties',
surfaceType: 'Paved Asphalt',
startLocation: 'Dahlonega Outpost',
endLocation: 'Vogel State Park',
gpxUrl: '/fixtures/blood-mountain.gpx',
curatorUid: 'owner',
curatorName: 'Morgan Vance',
createdAt: '2026-03-15T10:00:00Z',
},
{
id: 'r2',
clubId: 'ClubID',
title: 'Coastal Causeways Cruise',
description: 'Relaxed scenic run along salt marshes.',
distanceMiles: 32.0,
estimatedMinutes: 45,
elevationGainFt: 120,
curvatureRating: 'Scenic Cruise',
surfaceType: 'Paved Asphalt',
startLocation: 'Savannah Riverfront',
endLocation: 'Tybee Island',
gpxUrl: null,
curatorUid: 'owner',
curatorName: 'Morgan Vance',
createdAt: '2026-04-10T10:00:00Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchRoutes.mockResolvedValue(sampleRoutes);
mocks.createRoute.mockResolvedValue(undefined);
mocks.deleteRoute.mockResolvedValue(undefined);
mocks.sendChat.mockResolvedValue(undefined);
mocks.context.mockResolvedValue(mockContext);
mocks.auth.mockImplementation((_auth, deliver) => {
deliver({ uid: 'owner', isAnonymous: false });
return vi.fn();
});
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => {
onMessages([
{
id: 'msg-1',
clubId: 'ClubID',
channelId: 'general',
authorUid: 'owner',
authorDisplayName: 'Morgan Vance',
authorHandle: '@morgan_v',
authorDisplayRank: 'President',
authorRole: 'owner',
content: 'Welcome to squad chat!',
createdAt: '2026-06-01T12:00:00Z',
},
]);
return vi.fn();
});
});
afterEach(cleanup);
describe('Founder Routes Management', () => {
it('renders curated routes with metrics, waypoints, and curvature badges', async () => {
render(<FounderRoutes context={mockContext} />);
await screen.findByText('Blood Mountain Gap Loop');
expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined();
expect(screen.getByText('48.6 mi')).toBeDefined();
expect(screen.getByText('+3120 ft')).toBeDefined();
expect(screen.getByText('Dahlonega Outpost')).toBeDefined();
expect(screen.getAllByText('High Twisties').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Scenic Cruise').length).toBeGreaterThanOrEqual(1);
});
it('filters routes by search query and curvature rating', async () => {
render(<FounderRoutes context={mockContext} />);
await screen.findByText('Blood Mountain Gap Loop');
// Search filter
const searchInput = screen.getByPlaceholderText(/Search routes by name/i);
fireEvent.change(searchInput, { target: { value: 'Coastal' } });
expect(screen.queryByText('Blood Mountain Gap Loop')).toBeNull();
expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined();
// Reset search
fireEvent.change(searchInput, { target: { value: '' } });
expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined();
// Curvature select filter
const curvatureSelect = screen.getByLabelText(/Filter by Curvature/i);
fireEvent.change(curvatureSelect, { target: { value: 'High Twisties' } });
expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined();
expect(screen.queryByText('Coastal Causeways Cruise')).toBeNull();
});
it('opens curate route modal and submits new route', async () => {
render(<FounderRoutes context={mockContext} />);
await screen.findByText('Curate Route');
fireEvent.click(screen.getByRole('button', { name: /Curate Route/i }));
expect(screen.getByRole('heading', { name: 'Curate Club Route' })).toBeDefined();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Wolfpen Gap Apex Loop/i), {
target: { value: 'Cherohala High Ridge Run' },
});
fireEvent.change(screen.getByPlaceholderText(/Shell Outpost/i), {
target: { value: 'Tellico Plains' },
});
fireEvent.change(screen.getByPlaceholderText(/Vogel State Park/i), {
target: { value: 'Robbinsville Overlook' },
});
fireEvent.click(screen.getByRole('button', { name: /Save & Publish Route/i }));
await waitFor(() => {
expect(mocks.createRoute).toHaveBeenCalledWith(
'ClubID',
expect.objectContaining({
title: 'Cherohala High Ridge Run',
startLocation: 'Tellico Plains',
endLocation: 'Robbinsville Overlook',
})
);
});
});
it('opens delete confirmation modal and deletes route', async () => {
render(<FounderRoutes context={mockContext} />);
await screen.findByText('Blood Mountain Gap Loop');
const deleteButtons = screen.getAllByTitle('Delete Route');
fireEvent.click(deleteButtons[0]);
expect(screen.getByRole('heading', { name: 'Delete Club Route?' })).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: 'Confirm Delete' }));
await waitFor(() => {
expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1');
});
});
});
describe('Founder Chat Management', () => {
it('renders chat message stream and channel tabs', async () => {
render(<FounderChat context={mockContext} />);
await screen.findByText('Welcome to squad chat!');
expect(screen.getAllByText('#general-squad').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('#officers-lounge')).toBeDefined();
expect(screen.getByText('#road-intel')).toBeDefined();
expect(screen.getByText('Morgan Vance')).toBeDefined();
});
it('switches between channels and loads corresponding messages', async () => {
render(<FounderChat context={mockContext} />);
await screen.findByText('Welcome to squad chat!');
fireEvent.click(screen.getByText('#road-intel'));
await waitFor(() => {
expect(mocks.subscribeChat).toHaveBeenCalledWith(
'ClubID',
'ride-intel',
expect.any(Function),
expect.any(Function)
);
});
});
it('sends a new chat message on form submission', async () => {
render(<FounderChat context={mockContext} />);
await screen.findByText('Welcome to squad chat!');
const input = screen.getByPlaceholderText(/Message #general-squad/i);
fireEvent.change(input, { target: { value: 'Saturday ride briefing is posted!' } });
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
await waitFor(() => {
expect(mocks.sendChat).toHaveBeenCalledWith(
'ClubID',
'general',
'Saturday ride briefing is posted!'
);
});
});
it('restricts Officers Lounge channel for non-officers', async () => {
const memberContext: FounderClubContext = {
...mockContext,
currentUser: { uid: 'regular-member', role: 'member' },
permissions: {
...mockContext.permissions,
role: 'member',
canManageSettings: false,
},
};
render(<FounderChat context={memberContext} />);
fireEvent.click(screen.getByText('#officers-lounge'));
await screen.findByText('Officers Lounge Restricted');
expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined();
});
});
describe('Founder Community Page & Sidebar Integration', () => {
it('renders routes section via FounderCommunityPage', async () => {
render(<FounderCommunityPage clubId="atlantic-moto" section="routes" />);
await screen.findByText('Curated Club Routes');
expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false });
});
it('renders chat section via FounderCommunityPage', async () => {
render(<FounderCommunityPage clubId="atlantic-moto" section="chat" />);
await screen.findByText('Squad Live Chat');
expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false });
});
it('verifies all sidebar navigation links are enabled without SOON badges', () => {
render(
<FounderSidebar
club={mockContext.club}
permissions={mockContext.permissions}
activeNav="overview"
/>
);
const routesLink = screen.getByRole('link', { name: /Routes/i });
expect(routesLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/routes');
const chatLink = screen.getByRole('link', { name: /Chat/i });
expect(chatLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/chat');
const membersLink = screen.getByRole('link', { name: /Members/i });
expect(membersLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/members');
const wallLink = screen.getByRole('link', { name: /Wall/i });
expect(wallLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/wall');
const statsLink = screen.getByRole('link', { name: /Stats/i });
expect(statsLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/stats');
const moderationLink = screen.getByRole('link', { name: /Moderation/i });
expect(moderationLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/moderation');
// Confirm NO sidebar item has the SOON badge anymore
expect(screen.queryByText('SOON')).toBeNull();
});
});