From d86aa4583b807c354d2287210cf74bc2b74165f7 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 16 Jan 2026 01:40:01 +0100 Subject: [PATCH] website refactor --- .../admin/LeagueAdminSchedulePageClient.tsx | 67 +++++++++++-------- .../app/leagues/create/CreateLeagueWizard.tsx | 2 +- apps/website/app/sponsor/signup/page.tsx | 61 +++++++++-------- .../leagues/LeagueWizardCommandModel.ts | 2 +- .../leagues/RaceScheduleCommandModel.ts | 54 +++++++++++++++ .../sponsors/SponsorSignupCommandModel.ts | 67 +++++++++++++++++++ plans/website-architecture-audit.md | 25 +++++-- 7 files changed, 214 insertions(+), 64 deletions(-) create mode 100644 apps/website/lib/command-models/leagues/RaceScheduleCommandModel.ts create mode 100644 apps/website/lib/command-models/sponsors/SponsorSignupCommandModel.ts diff --git a/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx b/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx index 2368b9a28..6e12abe76 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx +++ b/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx @@ -17,6 +17,7 @@ import { updateRaceAction, deleteRaceAction } from './actions'; +import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; @@ -31,10 +32,9 @@ export function LeagueAdminSchedulePageClient() { // Form state const [seasonId, setSeasonId] = useState(''); - const [track, setTrack] = useState(''); - const [car, setCar] = useState(''); - const [scheduledAtIso, setScheduledAtIso] = useState(''); + const [form, setForm] = useState(() => new RaceScheduleCommandModel()); const [editingRaceId, setEditingRaceId] = useState(null); + const [errors, setErrors] = useState>({}); // Action state const [isPublishing, setIsPublishing] = useState(false); @@ -59,9 +59,8 @@ export function LeagueAdminSchedulePageClient() { const handleSeasonChange = (newSeasonId: string) => { setSeasonId(newSeasonId); setEditingRaceId(null); - setTrack(''); - setCar(''); - setScheduledAtIso(''); + setForm(new RaceScheduleCommandModel()); + setErrors({}); }; const handlePublishToggle = async () => { @@ -84,23 +83,24 @@ export function LeagueAdminSchedulePageClient() { }; const handleAddOrSave = async () => { - if (!selectedSeasonId || !scheduledAtIso) return; + if (!selectedSeasonId) return; + + const validationErrors = form.validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors as Record); + return; + } setIsSaving(true); try { const result = !editingRaceId - ? await createRaceAction(leagueId, selectedSeasonId, { track, car, scheduledAtIso }) - : await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, { - ...(track ? { track } : {}), - ...(car ? { car } : {}), - ...(scheduledAtIso ? { scheduledAtIso } : {}), - }); + ? await createRaceAction(leagueId, selectedSeasonId, form.toCommand()) + : await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, form.toCommand()); if (result.isOk()) { // Reset form - setTrack(''); - setCar(''); - setScheduledAtIso(''); + setForm(new RaceScheduleCommandModel()); + setErrors({}); setEditingRaceId(null); router.refresh(); } else { @@ -117,9 +117,12 @@ export function LeagueAdminSchedulePageClient() { if (!race) return; setEditingRaceId(raceId); - setTrack(race.track || ''); - setCar(race.car || ''); - setScheduledAtIso(race.scheduledAt.toISOString()); + setForm(new RaceScheduleCommandModel({ + track: race.track || '', + car: race.car || '', + scheduledAtIso: race.scheduledAt.toISOString(), + })); + setErrors({}); }; const handleDelete = async (raceId: string) => { @@ -142,9 +145,8 @@ export function LeagueAdminSchedulePageClient() { const handleCancelEdit = () => { setEditingRaceId(null); - setTrack(''); - setCar(''); - setScheduledAtIso(''); + setForm(new RaceScheduleCommandModel()); + setErrors({}); }; // Derived states @@ -198,16 +200,25 @@ export function LeagueAdminSchedulePageClient() { onEdit={handleEdit} onDelete={handleDelete} onCancelEdit={handleCancelEdit} - track={track} - car={car} - scheduledAtIso={scheduledAtIso} + track={form.track} + car={form.car} + scheduledAtIso={form.scheduledAtIso} editingRaceId={editingRaceId} isPublishing={isPublishing} isSaving={isSaving} isDeleting={deletingRaceId} - setTrack={setTrack} - setCar={setCar} - setScheduledAtIso={setScheduledAtIso} + setTrack={(val) => { + form.track = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} + setCar={(val) => { + form.car = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} + setScheduledAtIso={(val) => { + form.scheduledAtIso = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} /> ); }; diff --git a/apps/website/app/leagues/create/CreateLeagueWizard.tsx b/apps/website/app/leagues/create/CreateLeagueWizard.tsx index 72d5f6c51..4555249b3 100644 --- a/apps/website/app/leagues/create/CreateLeagueWizard.tsx +++ b/apps/website/app/leagues/create/CreateLeagueWizard.tsx @@ -607,7 +607,7 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar Create a new league - We'll also set up your first season in {steps.length} easy steps. + We'll also set up your first season in {steps.length} easy steps. A league is your long-term brand. Each season is a block of races you can run again and again. diff --git a/apps/website/app/sponsor/signup/page.tsx b/apps/website/app/sponsor/signup/page.tsx index 2b1e2336e..c3f6c7702 100644 --- a/apps/website/app/sponsor/signup/page.tsx +++ b/apps/website/app/sponsor/signup/page.tsx @@ -13,6 +13,7 @@ import { SponsorHero } from '@/components/sponsors/SponsorHero'; import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup'; import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard'; import { siteConfig } from '@/lib/siteConfig'; +import { SponsorSignupCommandModel } from '@/lib/command-models/sponsors/SponsorSignupCommandModel'; import { Building2, Mail, @@ -127,10 +128,8 @@ const PLATFORM_STATS = [ export default function SponsorSignupPage() { const shouldReduceMotion = useReducedMotion(); const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing'); + const [form, setForm] = useState(() => new SponsorSignupCommandModel()); const [formData, setFormData] = useState({ - companyName: '', - contactEmail: '', - websiteUrl: '', logoFile: null as File | null, password: '', confirmPassword: '', @@ -144,18 +143,9 @@ export default function SponsorSignupPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const newErrors: Record = {}; + const validationErrors = form.validate(); + const newErrors: Record = { ...validationErrors }; - if (!formData.companyName.trim()) { - newErrors.companyName = 'Company name required'; - } - - if (!formData.contactEmail.trim()) { - newErrors.contactEmail = 'Contact email required'; - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.contactEmail)) { - newErrors.contactEmail = 'Invalid email format'; - } - if (mode === 'signup') { if (!formData.password.trim()) { newErrors.password = 'Password required'; @@ -184,18 +174,19 @@ export default function SponsorSignupPage() { setSubmitting(true); try { + const command = form.toCommand(); // Note: Business logic for auth should be moved to a mutation // This is a temporary implementation for contract compliance const response = await fetch('/api/auth/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email: formData.contactEmail, + email: command.contactEmail, password: formData.password, - displayName: formData.companyName, + displayName: command.companyName, sponsorData: { - companyName: formData.companyName, - websiteUrl: formData.websiteUrl, + companyName: command.companyName, + websiteUrl: command.websiteUrl, interests: formData.interests, }, }), @@ -210,7 +201,7 @@ export default function SponsorSignupPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email: formData.contactEmail, + email: command.contactEmail, password: formData.password, }), }); @@ -468,8 +459,11 @@ export default function SponsorSignupPage() { setFormData({ ...formData, contactEmail: e.target.value })} + value={form.contactEmail} + onChange={(e) => { + form.contactEmail = e.target.value; + setForm(new SponsorSignupCommandModel(form.toCommand())); + }} placeholder="sponsor@company.com" variant={errors.contactEmail ? 'error' : 'default'} errorMessage={errors.contactEmail} @@ -566,8 +560,11 @@ export default function SponsorSignupPage() { setFormData({ ...formData, companyName: e.target.value })} + value={form.companyName} + onChange={(e) => { + form.companyName = e.target.value; + setForm(new SponsorSignupCommandModel(form.toCommand())); + }} placeholder="Your company name" variant={errors.companyName ? 'error' : 'default'} errorMessage={errors.companyName} @@ -583,8 +580,11 @@ export default function SponsorSignupPage() { setFormData({ ...formData, contactEmail: e.target.value })} + value={form.contactEmail} + onChange={(e) => { + form.contactEmail = e.target.value; + setForm(new SponsorSignupCommandModel(form.toCommand())); + }} placeholder="sponsor@company.com" variant={errors.contactEmail ? 'error' : 'default'} errorMessage={errors.contactEmail} @@ -600,9 +600,14 @@ export default function SponsorSignupPage() { setFormData({ ...formData, websiteUrl: e.target.value })} + value={form.websiteUrl} + onChange={(e) => { + form.websiteUrl = e.target.value; + setForm(new SponsorSignupCommandModel(form.toCommand())); + }} placeholder="https://company.com" + variant={errors.websiteUrl ? 'error' : 'default'} + errorMessage={errors.websiteUrl} /> @@ -782,4 +787,4 @@ export default function SponsorSignupPage() { ); -} \ No newline at end of file +} diff --git a/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts index 62cc3ae9c..49b58bb9c 100644 --- a/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts +++ b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts @@ -64,7 +64,7 @@ type LeagueWizardFormData = { raceTimeUtc?: string; }; stewarding: { - decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel'; + decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel' | 'admin_only' | 'single_steward' | 'committee_vote' | 'steward_vote'; requiredVotes?: number; requireDefense: boolean; defenseTimeLimit: number; diff --git a/apps/website/lib/command-models/leagues/RaceScheduleCommandModel.ts b/apps/website/lib/command-models/leagues/RaceScheduleCommandModel.ts new file mode 100644 index 000000000..ed5a8af66 --- /dev/null +++ b/apps/website/lib/command-models/leagues/RaceScheduleCommandModel.ts @@ -0,0 +1,54 @@ +/** + * RaceScheduleCommandModel + * + * UX-only model for managing race creation/editing state. + */ + +export interface RaceScheduleFormData { + track: string; + car: string; + scheduledAtIso: string; +} + +export interface RaceScheduleValidationErrors { + track?: string; + car?: string; + scheduledAtIso?: string; +} + +export class RaceScheduleCommandModel { + private _track: string; + private _car: string; + private _scheduledAtIso: string; + + constructor(initial: Partial = {}) { + this._track = initial.track || ''; + this._car = initial.car || ''; + this._scheduledAtIso = initial.scheduledAtIso || ''; + } + + get track(): string { return this._track; } + set track(value: string) { this._track = value; } + + get car(): string { return this._car; } + set car(value: string) { this._car = value; } + + get scheduledAtIso(): string { return this._scheduledAtIso; } + set scheduledAtIso(value: string) { this._scheduledAtIso = value; } + + validate(): RaceScheduleValidationErrors { + const errors: RaceScheduleValidationErrors = {}; + if (!this._track.trim()) errors.track = 'Track is required'; + if (!this._car.trim()) errors.car = 'Car is required'; + if (!this._scheduledAtIso) errors.scheduledAtIso = 'Date and time are required'; + return errors; + } + + toCommand(): { track: string; car: string; scheduledAtIso: string } { + return { + track: this._track, + car: this._car, + scheduledAtIso: this._scheduledAtIso, + }; + } +} diff --git a/apps/website/lib/command-models/sponsors/SponsorSignupCommandModel.ts b/apps/website/lib/command-models/sponsors/SponsorSignupCommandModel.ts new file mode 100644 index 000000000..3f4dbb555 --- /dev/null +++ b/apps/website/lib/command-models/sponsors/SponsorSignupCommandModel.ts @@ -0,0 +1,67 @@ +/** + * SponsorSignupCommandModel + * + * UX-only model for managing sponsor signup state. + */ + +export interface SponsorSignupFormData { + companyName: string; + contactEmail: string; + websiteUrl?: string; + industry?: string; +} + +export interface SponsorSignupValidationErrors { + companyName?: string; + contactEmail?: string; + websiteUrl?: string; +} + +export class SponsorSignupCommandModel { + private _companyName: string; + private _contactEmail: string; + private _websiteUrl: string; + private _industry: string; + + constructor(initial: Partial = {}) { + this._companyName = initial.companyName || ''; + this._contactEmail = initial.contactEmail || ''; + this._websiteUrl = initial.websiteUrl || ''; + this._industry = initial.industry || ''; + } + + get companyName(): string { return this._companyName; } + set companyName(value: string) { this._companyName = value; } + + get contactEmail(): string { return this._contactEmail; } + set contactEmail(value: string) { this._contactEmail = value; } + + get websiteUrl(): string { return this._websiteUrl; } + set websiteUrl(value: string) { this._websiteUrl = value; } + + get industry(): string { return this._industry; } + set industry(value: string) { this._industry = value; } + + validate(): SponsorSignupValidationErrors { + const errors: SponsorSignupValidationErrors = {}; + if (!this._companyName.trim()) errors.companyName = 'Company name is required'; + if (!this._contactEmail.trim()) { + errors.contactEmail = 'Contact email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._contactEmail)) { + errors.contactEmail = 'Invalid email format'; + } + if (this._websiteUrl && !this._websiteUrl.startsWith('http')) { + errors.websiteUrl = 'Website URL must start with http:// or https://'; + } + return errors; + } + + toCommand(): SponsorSignupFormData { + return { + companyName: this._companyName, + contactEmail: this._contactEmail, + websiteUrl: this._websiteUrl, + industry: this._industry, + }; + } +} diff --git a/plans/website-architecture-audit.md b/plans/website-architecture-audit.md index af9bef0b6..e675cc6be 100644 --- a/plans/website-architecture-audit.md +++ b/plans/website-architecture-audit.md @@ -250,14 +250,23 @@ Minimal guardrails that pay off: - The “mess” is client-side writes bypassing this. -### 5.3 React Query usage: is it even used? +### 5.4 Command Models: Are they useless? -Yes. +No. Command Models (e.g., [`LoginCommandModel`](apps/website/lib/command-models/auth/LoginCommandModel.ts)) remain valuable as **optional UX helpers**. -- React Query exists in dependencies: [`apps/website/package.json`](apps/website/package.json:16) -- There are dozens of hooks using it (search results in workspace). +- **Purpose**: They manage transient form state and perform client-side UX validation. +- **Relationship to Server Actions**: They prepare the DTO that is passed *to* the Server Action. +- **Boundary**: They belong to the client-side "intent collection" phase. Once the Server Action is called, the Command Model's job is done. +- **Redundancy**: They are **not redundant** with ViewModels. ViewModels are for the **read path** (API -> UI), while Command Models are for the **write path** (UI -> API). -The problem is not React Query for reads. The problem is React Query being used as the write mechanism. +### 5.5 Identified places for Command Model usage + +The following components currently use complex React state for forms and should be refactored to use Command Models: + +1. **[`CreateLeagueWizard.tsx`](apps/website/app/leagues/create/CreateLeagueWizard.tsx)**: Needs a `CreateLeagueCommandModel` to manage multi-step wizard state and validation. +2. **[`LeagueAdminSchedulePageClient.tsx`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx)**: Should use a `RaceScheduleCommandModel` for the track/car/date fields. +3. **[`ProtestDetailPageClient.tsx`](apps/website/app/leagues/[id]/stewarding/protests/[protestId]/ProtestDetailPageClient.tsx)**: Should fully leverage the existing [`ProtestDecisionCommandModel`](apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts). +4. **[`SponsorSignupPage`](apps/website/app/sponsor/signup/page.tsx)**: Needs a `SponsorSignupCommandModel` to clean up the `formData` and `errors` state. --- @@ -296,11 +305,15 @@ Concrete example of why you feel “ViewModels are pushed away by ViewData”: The following cleanups have been implemented: 1. **Deterministic Formatting**: Removed `toLocaleString()` and `toLocaleDateString()` from all templates. Introduced `NumberDisplay` and `DateDisplay` display objects for deterministic formatting. -2. **Server Action Migration**: Migrated `LeagueAdminSchedulePageClient.tsx` from React Query `useMutation` to Next.js Server Actions. +2. **Server Action Migration**: Migrated the schedule administration logic in `LeagueAdminSchedulePageClient.tsx` from React Query `useMutation` to Next.js Server Actions. 3. **Onboarding Consolidation**: Updated onboarding hooks to use server actions instead of direct service calls. 4. **ViewModel Builders**: Introduced `LeagueSummaryViewModelBuilder` to eliminate ad-hoc ViewData -> ViewModel mapping in `LeaguesPageClient.tsx`. 5. **React Query Deprecation**: Deprecated `usePageMutation` in `usePageData.ts`. 6. **Guardrails**: Added `gridpilot-rules/no-use-mutation-in-client` ESLint rule to prevent future React Query write usage. +7. **Command Models**: + - Created [`RaceScheduleCommandModel`](apps/website/lib/command-models/leagues/RaceScheduleCommandModel.ts) and integrated it into [`LeagueAdminSchedulePageClient.tsx`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx). + - Created [`SponsorSignupCommandModel`](apps/website/lib/command-models/sponsors/SponsorSignupCommandModel.ts) and integrated it into [`SponsorSignupPage`](apps/website/app/sponsor/signup/page.tsx). + - Ensured [`LeagueWizardCommandModel`](apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts) is ready for future refactoring of the complex wizard. ## 8) Next Steps for the Team