feat(website): add club curated routes, live squad chat, and portal navigation
This commit is contained in:
parent
368858670d
commit
c956ffd7bd
|
|
@ -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">
|
<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>
|
<span className="font-mono text-3xl font-extrabold text-brand-cyan tracking-wider">{initials}</span>
|
||||||
</div>
|
</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>
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
<span className="w-2.5 h-2.5 rounded-full bg-brand-emerald animate-pulse"></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-baseline space-x-2">
|
<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>
|
||||||
<div className="mt-2 text-xs text-brand-emerald font-mono flex items-center space-x-1.5">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -203,13 +203,13 @@ export default function AccountPage() {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-5">
|
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-5">
|
||||||
<div>
|
<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">
|
<h3 className="text-xl font-bold text-white flex items-center space-x-2 mt-0.5">
|
||||||
<span>Pro Telemetry Tier</span>
|
<span>Not connected</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 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>
|
</h3>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* Inclusions Pills */}
|
{/* Inclusions Pills */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
|
|
@ -217,33 +217,31 @@ export default function AccountPage() {
|
||||||
<div className="flex flex-wrap gap-2 text-xs font-mono">
|
<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">
|
<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>
|
<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>
|
||||||
<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">
|
<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>
|
<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>
|
||||||
<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">
|
<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>
|
<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>
|
||||||
<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">
|
<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>
|
<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 (<150ms)</span>
|
<span>Emergency dispatch is not provided</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Linked Hardware Details */}
|
{/* 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 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>
|
<div>
|
||||||
<span className="text-slate-400 block text-[11px]">Registered Dongle</span>
|
<span className="text-slate-400 block text-[11px]">Motorcycle hardware</span>
|
||||||
<span className="font-bold text-white">MotorSignal CAN-Link OBD-II</span>
|
<span className="font-bold text-white">Not available</span>
|
||||||
<div className="text-[11px] text-slate-400 mt-0.5">SN: MS-LINK-9021-X</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:text-right">
|
<div className="sm:text-right">
|
||||||
<span className="text-slate-400 block text-[11px]">Firmware Status</span>
|
<span className="text-slate-400 block text-[11px]">Device status</span>
|
||||||
<span className="text-brand-emerald font-semibold">v2.4.1 (Current)</span>
|
<span className="text-slate-300 font-semibold">No device linked</span>
|
||||||
<div className="text-[11px] text-slate-400 mt-0.5">Last Check: Today, 09:12 AM</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,10 +261,10 @@ export default function AccountPage() {
|
||||||
<span>Founder Portal</span>
|
<span>Founder Portal</span>
|
||||||
</Link>
|
</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">
|
<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 & Invoices
|
Billing unavailable
|
||||||
</button>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 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>
|
||||||
<div className="border-b border-brand-border pb-4 mb-4">
|
<div className="border-b border-brand-border pb-4 mb-4">
|
||||||
<h3 className="text-lg font-bold text-white">Security & Rider SOS</h3>
|
<h3 className="text-lg font-bold text-white">Account & Security</h3>
|
||||||
<p className="text-xs font-mono text-slate-400 mt-0.5">Emergency contact routing and app account link</p>
|
<p className="text-xs font-mono text-slate-400 mt-0.5">Account-link status and security settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 text-xs font-mono">
|
<div className="space-y-4 text-xs font-mono">
|
||||||
{/* Google App Link */}
|
{/* Google App Link */}
|
||||||
|
|
|
||||||
|
|
@ -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" />;
|
||||||
|
}
|
||||||
|
|
@ -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" />;
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||||
export default function ClubsPage() {
|
export default function ClubsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
<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 */}
|
{/* Top Command Matrix Hero */}
|
||||||
<section className="relative w-full py-space-4xl overflow-hidden bg-surface-container-lowest">
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,8 @@ export default function FeaturesPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let speed = 174;
|
let speed = 174;
|
||||||
let lean = 52.8;
|
let lean = 52.8;
|
||||||
let countdown = 8;
|
|
||||||
|
|
||||||
const speedEl = document.getElementById('speed-val');
|
const speedEl = document.getElementById('speed-val');
|
||||||
const leanEl = document.getElementById('lean-val');
|
const leanEl = document.getElementById('lean-val');
|
||||||
const countdownEl = document.getElementById('sos-countdown');
|
|
||||||
|
|
||||||
const i1 = setInterval(() => {
|
const i1 = setInterval(() => {
|
||||||
if (speedEl) {
|
if (speedEl) {
|
||||||
|
|
@ -26,17 +23,8 @@ export default function FeaturesPage() {
|
||||||
}
|
}
|
||||||
}, 800);
|
}, 800);
|
||||||
|
|
||||||
const i2 = setInterval(() => {
|
|
||||||
if (countdownEl) {
|
|
||||||
countdown--;
|
|
||||||
if (countdown < 0) countdown = 8;
|
|
||||||
countdownEl.textContent = '00:0' + countdown + 's';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(i1);
|
clearInterval(i1);
|
||||||
clearInterval(i2);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -64,7 +52,7 @@ export default function FeaturesPage() {
|
||||||
Surgical data precision for every apex, straightaway, and canyon.
|
Surgical data precision for every apex, straightaway, and canyon.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-2xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-space-sm pb-space-xs w-full max-w-sm">
|
<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 & Sectors
|
<span className="text-outline">02</span> Lap Analysis & Sectors
|
||||||
</a>
|
</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">
|
<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 & SOS Beacon
|
<span className="text-outline">03</span> Ride Records & Road Alerts
|
||||||
</a>
|
</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">
|
<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 & ECU Stream
|
<span className="text-outline">04</span> Phone Sensor Telemetry
|
||||||
</a>
|
</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">
|
<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 & Replay
|
<span className="text-outline">05</span> Cloud Sync & Replay
|
||||||
|
|
@ -432,10 +420,10 @@ export default function FeaturesPage() {
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-error"></span>
|
<span className="h-1.5 w-1.5 rounded-full bg-error"></span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
||||||
Emergency Impact Detection & SOS Beacon
|
Ride Records & Rider-Reported Hazards
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-body-lg text-body-lg text-on-surface-variant">
|
<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>
|
</p>
|
||||||
{/* Feature Bullets */}
|
{/* Feature Bullets */}
|
||||||
<div className="flex flex-col gap-space-sm my-space-xs">
|
<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>
|
<span className="material-symbols-outlined text-xl">sensors</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-headline-sm text-headline-sm text-on-surface"><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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,9 +443,9 @@ export default function FeaturesPage() {
|
||||||
<span className="material-symbols-outlined text-xl">satellite_alt</span>
|
<span className="material-symbols-outlined text-xl">satellite_alt</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-headline-sm text-headline-sm text-on-surface">Dual Cellular & 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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -466,9 +454,9 @@ export default function FeaturesPage() {
|
||||||
<span className="material-symbols-outlined text-xl">lock_reset</span>
|
<span className="material-symbols-outlined text-xl">lock_reset</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -477,11 +465,11 @@ export default function FeaturesPage() {
|
||||||
<div className="flex items-center gap-space-md pt-space-xs">
|
<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">
|
<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="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>
|
||||||
<div className="px-space-md py-2 rounded-xl bg-surface-container flex items-center gap-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-primary-container text-lg">shield</span>
|
<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>
|
</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 justify-between pb-space-md border-b-0 mb-space-md">
|
||||||
<div className="flex items-center gap-space-xs">
|
<div className="flex items-center gap-space-xs">
|
||||||
<span className="w-3 h-3 rounded-full bg-error animate-ping"></span>
|
<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>
|
</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>
|
||||||
<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="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">
|
<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>
|
<span className="material-symbols-outlined text-4xl">warning</span>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
<div className="font-telemetry-metric-xl text-telemetry-metric-xl text-error mb-space-sm" id="sos-countdown">
|
<div className="font-label-caps text-label-caps text-error mb-space-sm">
|
||||||
00:08s
|
SAMPLE
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-space-sm w-full max-w-xs">
|
<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">
|
<span className="flex-1 py-2 rounded-xl bg-surface-bright text-on-surface font-label-md text-label-md">
|
||||||
I Am OK (Cancel)
|
Close Preview
|
||||||
</button>
|
</span>
|
||||||
<button className="flex-1 py-2 rounded-xl bg-error text-on-error font-label-md text-label-md hover:opacity-90 transition-all">
|
<span className="flex-1 py-2 rounded-xl bg-error text-on-error font-label-md text-label-md">
|
||||||
Trigger Now
|
Report a Hazard
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span className="text-on-surface">43.9975° N, 11.3714° E (Mugello S3)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Rider Profile:</span>
|
<span>Availability:</span>
|
||||||
<span className="text-on-surface">A-Positive // Emergency Contact Linked</span>
|
<span className="text-on-surface">Sample content only</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Data Sent:</span>
|
<span>Emergency dispatch:</span>
|
||||||
<span className="text-secondary-fixed">Trail Crash Packet (CAN-Bus 250kbps)</span>
|
<span className="text-secondary-fixed">Not provided</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 className="flex flex-col md:flex-row md:items-end justify-between gap-space-md mb-space-2xl">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-space-xs mb-space-2xs">
|
<div className="flex items-center gap-space-xs mb-space-2xs">
|
||||||
<span className="font-code-mono text-code-mono text-primary">04 // OBD-II & 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>
|
<span className="h-1.5 w-1.5 rounded-full bg-primary-container"></span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
||||||
Direct CAN-bus & ECU Health Stream
|
Phone-Based Ride Telemetry
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-body-md text-body-md text-on-surface-variant max-w-md">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-md mb-space-2xl">
|
||||||
{/* Sensor 1: Coolant Temp */}
|
{/* Sensor 1: Coolant Temp */}
|
||||||
<div className="bg-surface-container-low p-space-lg rounded-xl shadow-sm hover:shadow-md transition-all">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/* SECTION 5: CLOUD SYNC & REPLAY */}
|
{/* SECTION 5: CLOUD SYNC & REPLAY */}
|
||||||
<section className="w-full bg-surface-container-lowest py-space-4xl relative" id="cloud-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 & Canyon Roads
|
Ready for Track & Canyon Roads
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-headline-xl text-headline-xl text-on-surface max-w-2xl mb-space-md tracking-tight">
|
<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>
|
</h2>
|
||||||
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-xl mb-space-xl">
|
<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 & 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>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-space-md w-full justify-center max-w-md">
|
<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">
|
<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
|
Start 30-Day Free Trial
|
||||||
</a>
|
</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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-space-lg flex items-center gap-space-lg font-code-mono text-code-mono text-outline">
|
<div className="mt-space-lg flex items-center gap-space-lg font-code-mono text-code-mono text-outline">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function PricingPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
<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 */}
|
{/* Subtle Ambient Glow Orbs */}
|
||||||
<div className="relative w-full overflow-hidden">
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,37 +11,49 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [deviceName, setDeviceName] = useState('Web browser');
|
const [deviceName, setDeviceName] = useState('Web browser');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [googleCanceled, setGoogleCanceled] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [googleBusy, setGoogleBusy] = useState(false);
|
const [googleBusy, setGoogleBusy] = useState(false);
|
||||||
|
|
||||||
async function handleGoogleSignIn() {
|
async function handleGoogleSignIn() {
|
||||||
setGoogleBusy(true);
|
setGoogleBusy(true);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setGoogleCanceled(false);
|
||||||
try {
|
try {
|
||||||
const credential = await signInWithPopup(firebaseAuth, googleProvider);
|
const credential = await signInWithPopup(firebaseAuth, googleProvider);
|
||||||
const idToken = await credential.user.getIdToken();
|
const idToken = await credential.user.getIdToken();
|
||||||
// Exchange firebase idToken with backend to set server session & ops cookies
|
// Exchange firebase idToken with backend to set server session & ops cookies
|
||||||
|
let response: Response | undefined;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/firebase-login', {
|
response = await fetch('/api/auth/firebase-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ id_token: idToken }),
|
body: JSON.stringify({ id_token: idToken }),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to legacy link if available
|
// Fallback to legacy link if available
|
||||||
try {
|
const legacyResponse = await fetch('/api/account/firebase-link', {
|
||||||
await fetch('/api/account/firebase-link', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ id_token: idToken }),
|
body: JSON.stringify({ id_token: idToken }),
|
||||||
});
|
});
|
||||||
} catch {
|
if (!legacyResponse.ok) {
|
||||||
// Backend link optional; Firebase auth is sufficient for client cloud data
|
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';
|
const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account';
|
||||||
window.location.assign(destination);
|
window.location.assign(destination);
|
||||||
} catch (error) {
|
} 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);
|
console.error('Google Sign-In Error:', error);
|
||||||
setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.');
|
setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -53,6 +65,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setGoogleCanceled(false);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/auth/${mode}`, {
|
const response = await fetch(`/api/auth/${mode}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -161,7 +174,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message && (
|
{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}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,12 @@ import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext }
|
||||||
import { FounderPortalShell } from './founder-portal-shell';
|
import { FounderPortalShell } from './founder-portal-shell';
|
||||||
import { FounderMembers } from './founder-members';
|
import { FounderMembers } from './founder-members';
|
||||||
import { FounderWall } from './founder-wall';
|
import { FounderWall } from './founder-wall';
|
||||||
|
import { FounderRoutes } from './founder-routes';
|
||||||
|
import { FounderChat } from './founder-chat';
|
||||||
import { OverviewSkeleton } from './skeleton-state';
|
import { OverviewSkeleton } from './skeleton-state';
|
||||||
import { SafeErrorState } from './safe-error-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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
|
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();
|
if (section === 'wall') document.getElementById('wall-post-content')?.focus();
|
||||||
else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`);
|
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} />
|
{section === 'members' && <FounderMembers key={context.club.id + context.currentUser.uid} context={context} />}
|
||||||
: <FounderWall 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>;
|
</FounderPortalShell>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -62,9 +62,9 @@ export function FounderSidebar({
|
||||||
badgeType: 'secondary',
|
badgeType: 'secondary',
|
||||||
disabled: false,
|
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: '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 },
|
{ 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 === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
||||||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||||
if (id === 'members') return `/clubs/${clubParam}/manage/members${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 === '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 === 'stats') return `/clubs/${clubParam}/manage/stats${queryParam}`;
|
||||||
if (id === 'moderation') return `/clubs/${clubParam}/manage/moderation${queryParam}`;
|
if (id === 'moderation') return `/clubs/${clubParam}/manage/moderation${queryParam}`;
|
||||||
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,12 @@ export function FounderTopBar({
|
||||||
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
|
<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-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' && (
|
{activeNav === 'applications' && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {
|
import {
|
||||||
collection, deleteDoc, doc, getDoc, limit, onSnapshot, orderBy, query,
|
collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query,
|
||||||
runTransaction, serverTimestamp, setDoc, updateDoc,
|
runTransaction, serverTimestamp, setDoc, updateDoc,
|
||||||
type DocumentData, type Transaction,
|
type DocumentData, type Transaction,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
|
|
@ -177,3 +177,317 @@ export async function deleteWallPost(clubId: string, postId: string): Promise<vo
|
||||||
callerUid();
|
callerUid();
|
||||||
await deleteDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId));
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,26 @@
|
||||||
import { getApp, getApps, initializeApp } from 'firebase/app';
|
import { getApp, getApps, initializeApp } from 'firebase/app';
|
||||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||||
import { getFirestore } from 'firebase/firestore';
|
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',
|
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
|
||||||
authDomain: 'motoapp-d22e0.firebaseapp.com',
|
authDomain: 'motoapp-d22e0.firebaseapp.com',
|
||||||
projectId: 'motoapp-d22e0',
|
projectId: 'motoapp-d22e0',
|
||||||
|
|
@ -14,3 +32,8 @@ const app = getApps().length ? getApp() : initializeApp({
|
||||||
export const firebaseAuth = getAuth(app);
|
export const firebaseAuth = getAuth(app);
|
||||||
export const googleProvider = new GoogleAuthProvider();
|
export const googleProvider = new GoogleAuthProvider();
|
||||||
export const firestore = getFirestore(app);
|
export const firestore = getFirestore(app);
|
||||||
|
|
||||||
|
if (useEmulators && !existingQaApp) {
|
||||||
|
connectAuthEmulator(firebaseAuth, `http://127.0.0.1:${authEmulatorPort}`);
|
||||||
|
connectFirestoreEmulator(firestore, '127.0.0.1', firestoreEmulatorPort);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue