From c014c2f04f4663540a71bd4fd3958f5901ca7d48 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 11:20:29 -0400 Subject: [PATCH 01/12] fix(website): stop club rank matrix from faking a permission save (WEB-W1) The Ranks & Roles "Operational Permission Matrix" previously waited ~400ms and showed a "Saved" state without persisting anything. No Firestore document, API route, or security rule stores or enforces a per-rank capability matrix; club authorization is role-based (Owner/Admin via getPortalPermissions). Persisting the matrix would require frozen security-rule changes and would create an unenforced permission model that misleads owners. Make the control truthful instead of faking success: - Render the matrix as a read-only reference of the standard governance model by default (persistenceAvailable=false): no Save/Reset controls, disabled toggles, a "View-only reference" indicator, and a notice stating that access is enforced by Owner/Admin roles and per-rank customization is planned. - Remove the fake setTimeout save handler and the unconditional success state. - Retain a forward-compatible editable path behind persistenceAvailable for when a backend contract ships. - Document the precise backend contract required to enable persistence. Tests: read-only matrix renders no Save control, reports no save, and does not mutate permissions even for Owner/Admin; editable path still fires toggle/save /reset when persistenceAvailable=true. Co-Authored-By: Claude Opus 4.8 --- .../[clubId]/manage/ranks-roles/page.tsx | 24 ++--- .../founder-portal/club-permission-matrix.tsx | 88 +++++++++++++---- .../club-rank-permissions.md | 98 +++++++++++++++++++ .../club-ranks-roles-interactive.test.tsx | 61 +++++++++--- 4 files changed, 225 insertions(+), 46 deletions(-) create mode 100644 website/docs/backend-contracts/club-rank-permissions.md diff --git a/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx b/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx index 6c74f0d..11fa882 100644 --- a/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx +++ b/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx @@ -34,7 +34,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { const [members, setMembers] = useState([]); const [permissions, setPermissions] = useState(DEFAULT_RANK_PERMISSIONS); const [selectedRankId, setSelectedRankId] = useState('president'); - const [isSavingPermissions, setIsSavingPermissions] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unauthenticated, setUnauthenticated] = useState(false); @@ -104,16 +103,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { })); } - function handleResetDefaults() { - setPermissions(DEFAULT_RANK_PERMISSIONS); - } - - async function handleSavePermissions() { - setIsSavingPermissions(true); - await new Promise((r) => setTimeout(r, 400)); - setIsSavingPermissions(false); - } - if (loading) { return (
@@ -220,16 +209,23 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { /> {/* Interactive Permission Toggle Switches Matrix */} + {/* + Per-rank capability permissions have no server-side persistence contract yet. + The enforced authorization model is role-based (Owner / Admin) via + getPortalPermissions in lib/club-data.ts, not this rank matrix. Rendering the + matrix read-only (persistenceAvailable={false}) keeps it truthful: it shows the + standard governance conventions without claiming a save that does not occur. + See website/docs/backend-contracts/club-rank-permissions.md for the proposed + contract that would flip persistenceAvailable to true. + */}
diff --git a/website/components/founder-portal/club-permission-matrix.tsx b/website/components/founder-portal/club-permission-matrix.tsx index ebb98d5..86dcfc9 100644 --- a/website/components/founder-portal/club-permission-matrix.tsx +++ b/website/components/founder-portal/club-permission-matrix.tsx @@ -9,10 +9,22 @@ interface ClubPermissionMatrixProps { permissions: Record>; selectedRankId: string | null; onTogglePermission: (rankId: string, capability: ClubCapability) => void; - onResetDefaults: () => void; - onSavePermissions: () => Promise; - isSaving: boolean; + onResetDefaults?: () => void; + onSavePermissions?: () => Promise; + isSaving?: boolean; canEdit: boolean; + /** + * Whether a server-side persistence contract for per-rank capability + * permissions actually exists. Defaults to false. + * + * The enforced club authorization model is role-based (Owner / Admin), not + * rank-based (see getPortalPermissions in lib/club-data.ts). No Firestore + * document or API persists this capability matrix today, so by default the + * matrix renders as a read-only reference of the standard governance model. + * The editable + save path is retained for when a backend contract ships + * (see website/docs/backend-contracts/club-rank-permissions.md). + */ + persistenceAvailable?: boolean; } export function ClubPermissionMatrix({ @@ -23,12 +35,19 @@ export function ClubPermissionMatrix({ onTogglePermission, onResetDefaults, onSavePermissions, - isSaving, + isSaving = false, canEdit, + persistenceAvailable = false, }: ClubPermissionMatrixProps) { const [saveSuccess, setSaveSuccess] = useState(false); + // Edit + save controls are only live when the caller both grants edit rights + // AND a real persistence backend is wired. Without persistence the matrix is + // a truthful read-only reference: it never mutates and never reports a save. + const editable = canEdit && persistenceAvailable && typeof onSavePermissions === 'function'; + async function handleSave() { + if (!editable || !onSavePermissions) return; await onSavePermissions(); setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 2500); @@ -48,19 +67,21 @@ export function ClubPermissionMatrix({

- Configure delegated operational privileges per squad rank. Administrative roles enforce backend limits. + Standard delegated operational privileges per squad rank. Administrative roles enforce backend limits.

- {canEdit && ( + {editable ? (
- + {onResetDefaults && ( + + )}
+ ) : ( + + visibility + View-only reference + )} + {/* Truthful availability notice: shown whenever persistence is not wired. */} + {!editable && ( +
+ + info + + + This matrix shows MotorSignal’s standard rank conventions for reference. Custom per-rank + permissions are not saved yet. Who can actually manage this club is enforced by the{' '} + Owner and{' '} + Admin roles in the member roster — see + Ranks & Roles above. Per-rank customization is planned. + +
+ )} + {/* Desktop / Tablet Matrix Table */}
@@ -142,17 +187,22 @@ export function ClubPermissionMatrix({ {capabilities.map((cap) => { const isGranted = rankPerms[cap.id] ?? false; const isImmutableOwner = rank.id === 'president'; + const toggleDisabled = !editable || isImmutableOwner; return (
+Manage Devices @@ -498,7 +517,7 @@ export default function AccountPage() { {/* BEGIN: SecurityAndSessionsGrid */}
{/* Sessions (7 cols) */} -
+

Active Device Sessions

@@ -600,27 +619,31 @@ export default function AccountPage() {
-{/* 2FA State */} +{/* 2FA State — reflects real Firebase multi-factor enrollment; enrollment is managed in the mobile app */}
Two-Factor Authentication (2FA) -Hardware TOTP Authenticator +{twoFactorEnrolled ? 'Authenticator enrolled' : 'Enroll in the mobile app'}
+{twoFactorEnrolled ? ( ENABLED +) : ( +NOT ENABLED +)}
-{/* Leaderboard Privacy */} +{/* Leaderboard Privacy — managed in the mobile app; the web app has no privacy toggle backend */}
Telemetry Privacy Mode -Public Leaderboard (Club Trophies) +Leaderboard visibility is managed in the mobile app
- +In mobile app
Telemetry Data Retention: 100% Retained -Security Logs +Review active sessions
diff --git a/website/docs/WEBSITE_FEATURE_AUDIT_2026-10-14.md b/website/docs/WEBSITE_FEATURE_AUDIT_2026-10-14.md new file mode 100644 index 0000000..59133f7 --- /dev/null +++ b/website/docs/WEBSITE_FEATURE_AUDIT_2026-10-14.md @@ -0,0 +1,124 @@ +# MotorSignal Website — Feature Audit Matrix + +Date: 2026-10-14 +Branch: `fix/website-production-hardening-20261014` +Baseline: PR #36 integration HEAD `4931920` (`codex/master-consolidation-20261012`) +Scope: `website/` only (Next.js 16.3.4 / React 19). Flutter, Drift, Firebase rules, +Cloudflare Worker, and backend production code are out of scope. + +Method: classifications below are grounded in the current source (file:line evidence), +not in filenames or prior docs. "Verified truthful" means the code was read and does a +real backend operation (or honestly reports unavailability); it does **not** claim any +live device / motorcycle validation. + +Legend — status: ✅ implemented & truthful · 🟠 partial · 🔴 broken / misleading · +🏷️ UI-only placeholder · 🔌 backend-dependent · 🗓️ deferred/roadmap. +Severity: P0 security / data-integrity / misleading success · P1 broken core workflow · +P2 incomplete / usability · P3 roadmap. + +## Summary + +The PR #36 baseline is substantially real. Club governance writes, group-ride +create/edit/delete/join, the garage/ride-history/export surface, device sessions, the +billing-portal fallback, and the Formation Radar are all implemented against real +Firestore/API paths or honestly report unavailability. The genuine defects are a focused +set of **fake success / fabricated-as-real** spots plus the static Support page. + +| # | Defect | File | Severity | Status | +|---|--------|------|----------|--------| +| W1 | Rank "permission matrix" faked a save (setTimeout, no write) | `app/clubs/[clubId]/manage/ranks-roles/page.tsx`, `components/founder-portal/club-permission-matrix.tsx` | P0 | ✅ FIXED (commit c014c2f) | +| W-2FA | Account shows "Two-Factor Authentication ENABLED" hardcoded for every signed-in user | `app/account/page.tsx:603-610` | P0 | 🔴 confirmed | +| W-devices | Account shows hardcoded "iOS Mobile App iPhone 16 Pro v4.1.0 · 14m ago" and "Cardo Packtalk Edge · Paired" as real connected devices | `app/account/page.tsx:463-488` | P1 | 🔴 confirmed | +| W-routes | Club routes: empty Firestore collection falls back to `FIXTURE_CLUB_ROUTES` (fake "Coastal Marshes" route + `/fixtures/routes/*.gpx`) in production | `lib/club-community.ts:265-268` | P1 | 🔴 confirmed | +| W-chat | Club chat: empty channel falls back to `FIXTURE_CHAT_MESSAGES` in production | `lib/club-community.ts:443-447` | P1 | 🔴 confirmed | +| W-metrics | Account dashboard `metrics` initialises to fabricated `12480 mi / 184 rides / 56.4°` before real data loads (persists if load fails) | `app/account/page.tsx:25` | P2 | 🟠 confirmed | +| W2 | Support Center is a static hero + `mailto:` only — no FAQ, no validation, no ticketing | `app/support/page.tsx` | P2 | 🔴 confirmed | +| W-deadctl | Dead/no-op controls: "Change" (telemetry privacy), "Manage Devices", "Security Logs"→`/account`, static "All Synced" | `app/account/page.tsx` | P2/P3 | 🟠 confirmed | + +## Feature matrix (by area) + +### Public site & navigation +- Homepage `app/page.tsx` + `components/landing/*` — ✅ marketing content. + - 🟠 `components/landing/TelemetryStrip.tsx:10-11` and `app/features/page.tsx:15-20` use + `Math.random` for animated counters. Decorative marketing, not presented as a user's + real telemetry. P3 — acceptable but worth a "demo" label. +- Navigation / responsive mobile nav `app/layout.tsx` — ⏳ not yet width-tested (Phase 8). + +### Authentication & account +- Login / register / forgot-password / verify-email — ✅ real (`components/auth-form.tsx`, + `api/auth/[action]`); covered by `tests/auth-form.test.tsx`, `auth-recovery-verification`. +- Account settings `app/account/page.tsx` — ✅ mostly real, with the W-2FA / W-devices / + W-metrics / W-deadctl defects above. +- Device sessions `app/account/page.tsx` + `api/account/sessions` — ✅ real list, revoke & + revoke-all, pagination, truthful empty/loading states. +- Subscription & billing — ✅ truthful: `/api/billing/portal` is unconfigured and the page + says so ("managed through the mobile app"); no fake success (`app/account/page.tsx:107-141`). + +### Garage & motorcycle management +- `components/app-cloud-data.tsx` — ✅ real Firestore: add / edit / delete / make-primary / + photo upload (`users/{uid}/motorcycles`), image via `/api/motorcycle-image`. + +### Ride history, telemetry sessions & export +- `components/app-cloud-data.tsx` — ✅ real rides from `users/{uid}/rides`, GPX & CSV export + handlers, delete, "Show more" pagination. CSV/GPX byte-integrity not yet re-verified (Phase 4/9). +- Telemetry Forensic Replay `components/web-telemetry-replay-modal.tsx` — ⏳ not yet fully + audited (map tiles, progress, playback smoothness — Phase 4). Tests exist + (`tests/telemetry-replay-export.test.tsx`, `replay-recorded-data.test.tsx`). + +### Clubs / Founder Portal +- Profile / access-settings / ownership-transfer / archive — ✅ real writes with owner/admin + authorization (`lib/club-data.ts`; demo branch uses labelled simulated latency only). +- Applications approve/decline — ✅ real transactional writes. +- Member rank assignment — ✅ real (`updateMemberRank`). +- Rank permission matrix — ✅ FIXED (W1): now a truthful read-only reference. +- Club routes — 🔴 W-routes fixture fallback. +- Club chat / wall — 🔴 W-chat fixture fallback (wall send path not yet fully audited). + +### Group Rides +- Public page `app/group-rides/page.tsx` — ✅ splits by auth: signed-out → + `GroupRidesShowcase` (clearly labelled "Sample Pack Formations" / "Radar Simulation"), + signed-in → `GroupRidesManagement`. Public path exposes no real private rides. +- Authenticated Hub `components/group-rides/group-rides-management.tsx` — ✅ real: + `/api/group-rides` discovery + Firestore `onSnapshot`; create/edit/delete/join/leave write + to `group_rides`. 🟠 join/ride identifier generated client-side with `Math.random` + (`:1008`) — weak/non-authoritative; 🟠 `INITIAL_GROUP_RIDES` seeds initial state (replaced + on first snapshot; can linger if no Firestore). P2. +- Address autocomplete / route distance / scheduling / join codes / QR — ⏳ not yet fully + audited (Phase 3). + +### Live Formation Radar +- `components/group-rides/live-pack-radar.tsx` — ✅ truthful. Real rides subscribe to + Firestore `live_presence` with authorization + loading + error states and **no** fixture + fallback; `INITIAL_RADAR_RIDERS` is used only for the showcase/demo and is explicitly + labelled "Public Demonstration (Simulated Pack)" with "[Simulation Demo]" ping copy. The + historical dummy-data concern is already remediated in this baseline. + +### Device connections / hardware +- 🔴 W-devices: account "App Connections" block hardcodes a connected iOS app and a paired + Cardo unit. Apple Watch row is truthful ("Not Connected (Unavailable on Web)"). +- Helmet Comms / GoPro / Apple Watch as first-class features — 🗓️ roadmap; must be labelled. + +### Support Center +- 🔴 W2: `app/support/page.tsx` is one static hero + `mailto:`. No public ticket backend + exists (the `api/ops/support/tickets/*` routes are the internal operations console, not a + public endpoint). Phase 6 remediation: keep email fallback, add grounded FAQ + validated + contact UX; do not simulate ticket submission. + +### Operations console (`app/api/ops/*`, `components/ops/*`, `app/admin`) +- Out of the brief's feature list; appears to be a separate internal console. Not modified. + +## Remaining P0–P3 work (website) + +- **P0** — W-2FA (false security state). Fix: show real MFA state / "managed in mobile app", + never a hardcoded "ENABLED". +- **P1** — W-devices, W-routes, W-chat (fabricated-as-real). Fix: real state or truthful + empty/unavailable; never fixtures in production paths. +- **P2** — W2 Support (FAQ + validation, email fallback), W-metrics (init to loading/0), + W-deadctl (wire or remove dead controls), group-ride client-side id generation, mobile + responsiveness pass (Phase 8), telemetry replay deep audit (Phase 4). +- **P3** — marketing `Math.random` counters (label as demo), hardware roadmap labelling. + +## Open product questions (do not block P0/P1 truthfulness fixes) +1. Support: confirm no public ticket backend is planned for this release (default: email + FAQ). +2. Device-connections block: remove, or convert to a truthful "manage in mobile app" state? +3. Club routes/chat empty states: plain empty state, or keep clearly-labelled "example" content? diff --git a/website/tests/account-truthful-states.test.tsx b/website/tests/account-truthful-states.test.tsx new file mode 100644 index 0000000..cc47479 --- /dev/null +++ b/website/tests/account-truthful-states.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import AccountPage from '../app/account/page'; + +// Account page only needs next/navigation stubbed; firebase-client initialises under the +// staging test env and reports a signed-out user (onAuthStateChanged -> null). +vi.mock('next/navigation', () => ({ + usePathname: () => '/account', + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), +})); + +describe('WEB-W1b/W-2FA/W-devices: account page truthful states', () => { + it('does not fabricate a connected mobile device, paired comms, or sync status', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('App Connections & Sync')).toBeDefined(); + }); + + // Fabricated device/pairing claims must be gone. + expect(screen.queryByText(/iPhone 16 Pro/)).toBeNull(); + expect(screen.queryByText(/Cardo Packtalk Edge/)).toBeNull(); + expect(screen.queryByText(/^Paired$/)).toBeNull(); + expect(screen.queryByText(/^14m ago$/)).toBeNull(); + expect(screen.queryByText(/^All Synced$/)).toBeNull(); + + // Truthful states are present instead. + expect(screen.getByText('Authenticated Devices')).toBeDefined(); + expect(screen.getByText('Helmet Comms & Accessories')).toBeDefined(); + expect(screen.getAllByText(/Unavailable on web/i).length).toBeGreaterThan(0); + }); + + it('never shows 2FA as ENABLED unless a factor is actually enrolled (signed-out = NOT ENABLED)', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Two-Factor Authentication (2FA)')).toBeDefined(); + }); + + // Exact-match: the badge node reads "NOT ENABLED" for a user with no enrolled factor, + // so an exact "ENABLED" node must not exist. + expect(screen.getByText('NOT ENABLED')).toBeDefined(); + expect(screen.queryByText('ENABLED')).toBeNull(); + }); +}); From 2cb3ef9694106f113941f8079d8c26fa534576aa Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 12:08:15 -0400 Subject: [PATCH 03/12] fix(website): label fallback club routes and chat as examples (W-routes, W-chat) When a club has no curated routes or a chat channel has no messages, the data layer returned fixture content with no indication it was sample data, so riders could mistake MotorSignal's "Coastal Marshes" route or canned chat messages for their club's real data. Flag fixture-sourced content and label it in the UI: - Add isExample to ClubRoute and ChatMessage; set it on every fixture fallback path (empty Firestore collection/channel and the dev demo preview). - Founder Routes: amber "Examples" count, an explanatory banner, and a per-card "Example" badge when the routes shown are samples. - Founder Chat: an "Example thread" pill (instead of "Live Connected") and a banner when the only messages are examples, so the header never claims a live conversation that does not exist. Tests: example-labelled routes render the banner/badge/count; example-labelled chat renders the banner and "Example thread" pill and does not claim "Live Connected". Existing route/chat tests (real data, no isExample) remain green. Co-Authored-By: Claude Opus 4.8 --- .../founder-portal/founder-chat.tsx | 28 ++++++++++--- .../founder-portal/founder-routes.tsx | 35 +++++++++++++--- website/lib/club-community.ts | 16 ++++--- website/tests/founder-routes-chat.test.tsx | 42 +++++++++++++++++++ 4 files changed, 106 insertions(+), 15 deletions(-) diff --git a/website/components/founder-portal/founder-chat.tsx b/website/components/founder-portal/founder-chat.tsx index 1e54c20..1707e08 100644 --- a/website/components/founder-portal/founder-chat.tsx +++ b/website/components/founder-portal/founder-chat.tsx @@ -60,6 +60,7 @@ export function FounderChat({ context }: FounderChatProps) { const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0]; const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer; + const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample); useEffect(() => { setError(null); @@ -67,7 +68,7 @@ export function FounderChat({ context }: FounderChatProps) { if (isDemoFixture) { const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || []; - setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId }))); + setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true }))); } else { unsubscribe = subscribeClubChat( club.id, @@ -143,10 +144,17 @@ export function FounderChat({ context }: FounderChatProps) {

Squad Live Chat

- - - Live Connected - + {hasExampleMessages ? ( + + visibility + Example thread + + ) : ( + + + Live Connected + + )}

Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions. @@ -179,6 +187,16 @@ export function FounderChat({ context }: FounderChatProps) { + {hasExampleMessages && ( +

+ info + + These are example messages — this + channel has no posts yet. Send a message to start the real conversation. + +
+ )} + {error && (
diff --git a/website/components/founder-portal/founder-routes.tsx b/website/components/founder-portal/founder-routes.tsx index 9c5c2b5..d4c0a13 100644 --- a/website/components/founder-portal/founder-routes.tsx +++ b/website/components/founder-portal/founder-routes.tsx @@ -178,6 +178,8 @@ export function FounderRoutes({ context }: FounderRoutesProps) { const canCurate = permissions.canPlanRides || permissions.canManageSettings; + const hasExampleRoutes = routes.length > 0 && routes.every((r) => r.isExample); + return (
{/* Top Header Card */} @@ -187,8 +189,14 @@ export function FounderRoutes({ context }: FounderRoutesProps) {

Curated Club Routes

- - {routes.length} Available + + {hasExampleRoutes ? `${routes.length} Examples` : `${routes.length} Available`}

@@ -207,6 +215,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) { )}

+ {hasExampleRoutes && ( +
+ info + + These are example routes — your club + hasn’t curated any yet. Curate a route to replace them with your own. + +
+ )} + {error && (
@@ -314,9 +332,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
{/* Card Header & Badge */}
-

- {route.title} -

+
+

+ {route.title} +

+ {route.isExample && ( + + Example + + )} +
{ if (options?.allowDemoPreview) { - return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId })); + return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true })); } 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 })); + // No custom routes uploaded yet: surface sample starter routes, explicitly flagged as + // examples so the UI can label them and they are never mistaken for real club routes. + return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true })); } return snap.docs.map((d) => { const data = d.data(); @@ -358,6 +361,8 @@ export interface ChatMessage { authorRole: ClubRole; content: string; createdAt: string; + /** True when this is MotorSignal sample/example content, not a real posted message. */ + isExample?: boolean; } export const FIXTURE_CHAT_MESSAGES: Record = { @@ -441,9 +446,10 @@ export function subscribeClubChat( q, (snap) => { if (snap.empty) { - // Fallback to fixture messages if channel has no messages yet + // No messages in this channel yet: surface sample messages, explicitly flagged as + // examples so the UI can label them and they are never mistaken for real posts. const initial = FIXTURE_CHAT_MESSAGES[channelId] || []; - onMessages(initial.map((m) => ({ ...m, clubId, channelId }))); + onMessages(initial.map((m) => ({ ...m, clubId, channelId, isExample: true }))); return; } const msgs: ChatMessage[] = snap.docs.map((d) => { diff --git a/website/tests/founder-routes-chat.test.tsx b/website/tests/founder-routes-chat.test.tsx index 625c276..fbb481b 100644 --- a/website/tests/founder-routes-chat.test.tsx +++ b/website/tests/founder-routes-chat.test.tsx @@ -250,6 +250,18 @@ describe('Founder Routes Management', () => { expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1'); }); }); + + // WEB-W-routes regression: fixture fallback routes must be labelled as examples so a + // rider can never mistake sample content for the club's real curated routes. + it('labels example routes when the club has none of its own', async () => { + mocks.fetchRoutes.mockResolvedValue(sampleRoutes.map((r) => ({ ...r, isExample: true }))); + render(); + await screen.findByText('Blood Mountain Gap Loop'); + + expect(screen.getByText(/example routes/i)).toBeDefined(); + expect(screen.getByText(/Examples$/)).toBeDefined(); + expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1); + }); }); describe('Founder Chat Management', () => { @@ -296,6 +308,36 @@ describe('Founder Chat Management', () => { }); }); + // WEB-W-chat regression: fixture fallback messages must be labelled as examples and the + // header must not claim a live connection when only sample content is shown. + it('labels example messages when the channel has no real posts', async () => { + mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => { + onMessages([ + { + id: 'example-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', + isExample: true, + }, + ]); + return vi.fn(); + }); + + render(); + await screen.findByText('Welcome to squad chat!'); + + expect(screen.getByText('Example thread')).toBeDefined(); + expect(screen.getByText(/example messages/i)).toBeDefined(); + expect(screen.queryByText('Live Connected')).toBeNull(); + }); + it('restricts Officers Lounge channel for non-officers', async () => { const memberContext: FounderClubContext = { ...mockContext, From 3cd8bbefa37ca6f030ec7dce3b8be394bea0c17a Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 12:11:20 -0400 Subject: [PATCH 04/12] feat(website): grounded FAQ and validated contact on Support Center (W2) The Support page was a single static line plus a mailto link. There is no public ticket backend (only the internal ops console), so rather than simulate ticket submission, improve the page truthfully: - Add an FAQ grounded in features that actually ship (offline recording/sync, billing + 2FA managed in the mobile app, GPX/CSV export, session revocation, radar live-presence rules, public vs authenticated group rides). - Add a validated contact form (name/email/topic/message) that opens a prefilled email in the visitor's own client. It never claims a ticket was filed and states plainly that the message is not sent until the visitor sends it. - Keep support@motorsignal.app as the real, working fallback channel. - Preserve the existing public-page visual system (content-page / eyebrow / faq-section). Tests: FAQ + real mailto render; invalid input shows validation errors and no submission claim; valid input opens a mailto (no simulated ticket) with the topic and email encoded. Co-Authored-By: Claude Opus 4.8 --- website/app/support/page.tsx | 73 +++++++++- website/components/support-contact-form.tsx | 153 ++++++++++++++++++++ website/tests/support-contact.test.tsx | 74 ++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 website/components/support-contact-form.tsx create mode 100644 website/tests/support-contact.test.tsx diff --git a/website/app/support/page.tsx b/website/app/support/page.tsx index 4e09c33..7539bc3 100644 --- a/website/app/support/page.tsx +++ b/website/app/support/page.tsx @@ -1 +1,72 @@ -export default function SupportPage() { return
Support

Help for the road ahead.

Account and product support will be available here as the MotorSignal web platform comes online.

Contact support
; } +import { SupportContactForm } from '../../components/support-contact-form'; + +const SUPPORT_EMAIL = 'support@motorsignal.app'; + +// Grounded in features that actually ship today. Keep answers truthful: anything managed +// only in the mobile app, or not yet available on the web, is stated plainly. +const FAQ: { q: string; a: string }[] = [ + { + q: 'Can I use MotorSignal without an internet connection?', + a: 'Yes. Ride recording and telemetry run on-device, so you can record offline and while signed out. Your rides sync to the cloud once you are signed in and back online — nothing is lost in the meantime.', + }, + { + q: 'How do I manage my subscription or find my invoices?', + a: 'Subscriptions, payment methods, and invoices are managed in the MotorSignal mobile app. The web billing portal is not available yet, so the Account page will point you to the app for billing changes.', + }, + { + q: 'How do I turn on two-factor authentication (2FA)?', + a: 'Two-factor authentication is enrolled in the mobile app. The web Account page reflects your real 2FA status — it will show “Enabled” only once a factor is actually enrolled.', + }, + { + q: 'Can I export my ride data?', + a: 'Yes. On the web Account page, each ride can be exported as a GPX track or a CSV of its telemetry. Your historical exports are not altered after the fact.', + }, + { + q: 'How do I sign out other devices or browsers?', + a: 'Open Account → Active Device Sessions. You can revoke an individual session, or revoke all sessions at once. The session you are currently using is marked “this device”.', + }, + { + q: 'Why don’t I see live riders on the web radar?', + a: 'The Formation Radar shows live rider positions only for a group ride you are an accepted participant in. Outside of that it displays a clearly labelled demonstration, not real riders.', + }, + { + q: 'What’s the difference between the public Group Rides page and my hub?', + a: 'Signed out, the Group Rides page is a public showcase with sample formations. Signed in, it becomes your hub where you create, manage, join, and leave real rides.', + }, +]; + +export default function SupportPage() { + return ( +
+ Support +

Help for the road ahead.

+

+ Search the answers below for the quickest fix. If you still need a hand, send us an email and + we’ll get back to you. +

+ +
+

Contact support

+

+ Reach the team at{' '} + {SUPPORT_EMAIL}. Prefer a form? Fill this in and it will + open a prefilled email for you to send. +

+
+ +
+
+ +
+ Common questions +

Frequently asked

+ {FAQ.map((item) => ( +
+ {item.q} +

{item.a}

+
+ ))} +
+
+ ); +} diff --git a/website/components/support-contact-form.tsx b/website/components/support-contact-form.tsx new file mode 100644 index 0000000..9bc6c9f --- /dev/null +++ b/website/components/support-contact-form.tsx @@ -0,0 +1,153 @@ +'use client'; + +import React, { useState } from 'react'; + +const SUPPORT_EMAIL = 'support@motorsignal.app'; + +const CATEGORIES = [ + 'Account & sign-in', + 'Subscription & billing', + 'Group rides', + 'Clubs', + 'Telemetry & ride data', + 'Bug report', + 'Something else', +] as const; + +type Errors = { name?: string; email?: string; message?: string }; + +// Public support contact. There is no public ticket backend, so this never claims a +// ticket was filed: it validates input and opens the visitor's own email client with a +// prefilled message. Email remains the real, working support channel. +export function SupportContactForm() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [category, setCategory] = useState<(typeof CATEGORIES)[number]>(CATEGORIES[0]); + const [message, setMessage] = useState(''); + const [errors, setErrors] = useState({}); + const [opened, setOpened] = useState(false); + + function validate(): Errors { + const next: Errors = {}; + if (!name.trim()) next.name = 'Please tell us your name.'; + // Pragmatic email check: a single @ with a dot in the domain. + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + next.email = 'Enter a valid email address so we can reply.'; + } + if (message.trim().length < 10) { + next.message = 'Add a few more details (at least 10 characters).'; + } + return next; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const found = validate(); + setErrors(found); + if (Object.keys(found).length > 0) { + setOpened(false); + return; + } + + const subject = `[${category}] Support request from ${name.trim()}`; + const body = + `Name: ${name.trim()}\n` + + `Email: ${email.trim()}\n` + + `Category: ${category}\n\n` + + `${message.trim()}\n`; + const mailto = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + + // Open the visitor's email client. We do not and cannot confirm delivery here. + if (typeof window !== 'undefined') { + window.location.href = mailto; + } + setOpened(true); + } + + const inputClass = + 'w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500'; + + return ( +
+
+
+ + setName(e.target.value)} + className={inputClass} + placeholder="Alex Rider" + aria-invalid={Boolean(errors.name)} + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setEmail(e.target.value)} + className={inputClass} + placeholder="you@example.com" + aria-invalid={Boolean(errors.email)} + /> + {errors.email &&

{errors.email}

} +
+
+ +
+ + +
+ +
+ +