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 <noreply@anthropic.com>
This commit is contained in:
Developer 2026-10-14 12:11:20 -04:00
parent 2cb3ef9694
commit 3cd8bbefa3
3 changed files with 299 additions and 1 deletions

View File

@ -1 +1,72 @@
export default function SupportPage() { return <section className="hero"><span className="eyebrow">Support</span><h1>Help for the road ahead.</h1><p>Account and product support will be available here as the MotorSignal web platform comes online.</p><a className="button" href="mailto:support@motorsignal.app">Contact support</a></section>; }
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 (
<section className="content-page">
<span className="eyebrow">Support</span>
<h1>Help for the road ahead.</h1>
<p>
Search the answers below for the quickest fix. If you still need a hand, send us an email and
we&rsquo;ll get back to you.
</p>
<div className="panel" style={{ marginTop: 28 }}>
<h2>Contact support</h2>
<p>
Reach the team at{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>. Prefer a form? Fill this in and it will
open a prefilled email for you to send.
</p>
<div style={{ marginTop: 20 }}>
<SupportContactForm />
</div>
</div>
<div className="faq-section">
<span className="eyebrow">Common questions</span>
<h2>Frequently asked</h2>
{FAQ.map((item) => (
<details key={item.q}>
<summary>{item.q}</summary>
<p>{item.a}</p>
</details>
))}
</div>
</section>
);
}

View File

@ -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<Errors>({});
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 (
<form className="space-y-4 text-left" onSubmit={handleSubmit} noValidate>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label htmlFor="support-name" className="block text-xs font-mono tracking-wide text-on-surface-variant">
Your name
</label>
<input
id="support-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputClass}
placeholder="Alex Rider"
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <p className="form-error" role="alert">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<label htmlFor="support-email" className="block text-xs font-mono tracking-wide text-on-surface-variant">
Email
</label>
<input
id="support-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
placeholder="you@example.com"
aria-invalid={Boolean(errors.email)}
/>
{errors.email && <p className="form-error" role="alert">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="support-category" className="block text-xs font-mono tracking-wide text-on-surface-variant">
Topic
</label>
<select
id="support-category"
value={category}
onChange={(e) => setCategory(e.target.value as (typeof CATEGORIES)[number])}
className={inputClass}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label htmlFor="support-message" className="block text-xs font-mono tracking-wide text-on-surface-variant">
How can we help?
</label>
<textarea
id="support-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={5}
className={inputClass}
placeholder="Describe what you ran into, including your device and app version if relevant."
aria-invalid={Boolean(errors.message)}
/>
{errors.message && <p className="form-error" role="alert">{errors.message}</p>}
</div>
<button type="submit" className="button">Compose support email</button>
{opened && (
<p className="form-message" role="status">
Your email app should have opened with the details filled in. If nothing happened, email{' '}
<a className="underline" href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> directly.
</p>
)}
<p className="form-divider">
This opens your own email app — we don&rsquo;t submit a ticket on your behalf, so your message
isn&rsquo;t sent until you send it.
</p>
</form>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import SupportPage from '../app/support/page';
import { SupportContactForm } from '../components/support-contact-form';
describe('WEB-W2: public Support Center', () => {
afterEach(cleanup);
it('renders a grounded FAQ and a real email contact channel', () => {
render(<SupportPage />);
expect(screen.getByText('Frequently asked')).toBeDefined();
expect(screen.getByText(/Can I export my ride data\?/i)).toBeDefined();
expect(screen.getByText(/How do I turn on two-factor authentication/i)).toBeDefined();
// Email fallback is present as a real mailto link.
const mailLinks = screen.getAllByRole('link', { name: /support@motorsignal\.app/i });
expect(mailLinks.length).toBeGreaterThan(0);
expect(mailLinks[0].getAttribute('href')).toBe('mailto:support@motorsignal.app');
});
it('validates input and does not claim any submission on invalid data', () => {
render(<SupportContactForm />);
fireEvent.click(screen.getByRole('button', { name: /Compose support email/i }));
expect(screen.getByText(/tell us your name/i)).toBeDefined();
expect(screen.getByText(/valid email address/i)).toBeDefined();
expect(screen.getByText(/at least 10 characters/i)).toBeDefined();
// No success/opened confirmation on invalid submit.
expect(screen.queryByText(/email app should have opened/i)).toBeNull();
// Truthful disclaimer is always present.
expect(screen.getByText(/don.?t submit a ticket on your behalf/i)).toBeDefined();
});
it('opens a prefilled email (no simulated ticket) when the form is valid', () => {
const originalLocation = window.location;
const hrefSpy = vi.fn();
// Replace window.location with a mock that records href assignments.
Object.defineProperty(window, 'location', {
configurable: true,
value: {
set href(value: string) {
hrefSpy(value);
},
get href() {
return '';
},
},
});
try {
render(<SupportContactForm />);
fireEvent.change(screen.getByLabelText('Your name'), { target: { value: 'Alex Rider' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'alex@example.com' } });
fireEvent.change(screen.getByLabelText('Topic'), { target: { value: 'Bug report' } });
fireEvent.change(screen.getByLabelText(/How can we help/i), {
target: { value: 'The replay map tiles are not loading on my ride.' },
});
fireEvent.click(screen.getByRole('button', { name: /Compose support email/i }));
expect(hrefSpy).toHaveBeenCalledTimes(1);
const url = hrefSpy.mock.calls[0][0] as string;
expect(url.startsWith('mailto:support@motorsignal.app?')).toBe(true);
expect(decodeURIComponent(url)).toContain('[Bug report]');
expect(decodeURIComponent(url)).toContain('alex@example.com');
expect(screen.getByText(/email app should have opened/i)).toBeDefined();
} finally {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
}
});
});