website refactor
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
updateRaceAction,
|
updateRaceAction,
|
||||||
deleteRaceAction
|
deleteRaceAction
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
@@ -31,10 +32,9 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [seasonId, setSeasonId] = useState<string>('');
|
const [seasonId, setSeasonId] = useState<string>('');
|
||||||
const [track, setTrack] = useState('');
|
const [form, setForm] = useState(() => new RaceScheduleCommandModel());
|
||||||
const [car, setCar] = useState('');
|
|
||||||
const [scheduledAtIso, setScheduledAtIso] = useState('');
|
|
||||||
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Action state
|
// Action state
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
@@ -59,9 +59,8 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
const handleSeasonChange = (newSeasonId: string) => {
|
const handleSeasonChange = (newSeasonId: string) => {
|
||||||
setSeasonId(newSeasonId);
|
setSeasonId(newSeasonId);
|
||||||
setEditingRaceId(null);
|
setEditingRaceId(null);
|
||||||
setTrack('');
|
setForm(new RaceScheduleCommandModel());
|
||||||
setCar('');
|
setErrors({});
|
||||||
setScheduledAtIso('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublishToggle = async () => {
|
const handlePublishToggle = async () => {
|
||||||
@@ -84,23 +83,24 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOrSave = async () => {
|
const handleAddOrSave = async () => {
|
||||||
if (!selectedSeasonId || !scheduledAtIso) return;
|
if (!selectedSeasonId) return;
|
||||||
|
|
||||||
|
const validationErrors = form.validate();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors as Record<string, string>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = !editingRaceId
|
const result = !editingRaceId
|
||||||
? await createRaceAction(leagueId, selectedSeasonId, { track, car, scheduledAtIso })
|
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
|
||||||
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, {
|
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, form.toCommand());
|
||||||
...(track ? { track } : {}),
|
|
||||||
...(car ? { car } : {}),
|
|
||||||
...(scheduledAtIso ? { scheduledAtIso } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.isOk()) {
|
if (result.isOk()) {
|
||||||
// Reset form
|
// Reset form
|
||||||
setTrack('');
|
setForm(new RaceScheduleCommandModel());
|
||||||
setCar('');
|
setErrors({});
|
||||||
setScheduledAtIso('');
|
|
||||||
setEditingRaceId(null);
|
setEditingRaceId(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
@@ -117,9 +117,12 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
if (!race) return;
|
if (!race) return;
|
||||||
|
|
||||||
setEditingRaceId(raceId);
|
setEditingRaceId(raceId);
|
||||||
setTrack(race.track || '');
|
setForm(new RaceScheduleCommandModel({
|
||||||
setCar(race.car || '');
|
track: race.track || '',
|
||||||
setScheduledAtIso(race.scheduledAt.toISOString());
|
car: race.car || '',
|
||||||
|
scheduledAtIso: race.scheduledAt.toISOString(),
|
||||||
|
}));
|
||||||
|
setErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (raceId: string) => {
|
const handleDelete = async (raceId: string) => {
|
||||||
@@ -142,9 +145,8 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setEditingRaceId(null);
|
setEditingRaceId(null);
|
||||||
setTrack('');
|
setForm(new RaceScheduleCommandModel());
|
||||||
setCar('');
|
setErrors({});
|
||||||
setScheduledAtIso('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derived states
|
// Derived states
|
||||||
@@ -198,16 +200,25 @@ export function LeagueAdminSchedulePageClient() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onCancelEdit={handleCancelEdit}
|
onCancelEdit={handleCancelEdit}
|
||||||
track={track}
|
track={form.track}
|
||||||
car={car}
|
car={form.car}
|
||||||
scheduledAtIso={scheduledAtIso}
|
scheduledAtIso={form.scheduledAtIso}
|
||||||
editingRaceId={editingRaceId}
|
editingRaceId={editingRaceId}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
isDeleting={deletingRaceId}
|
isDeleting={deletingRaceId}
|
||||||
setTrack={setTrack}
|
setTrack={(val) => {
|
||||||
setCar={setCar}
|
form.track = val;
|
||||||
setScheduledAtIso={setScheduledAtIso}
|
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()));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
|||||||
Create a new league
|
Create a new league
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text size="sm" color="text-gray-500" block>
|
<Text size="sm" color="text-gray-500" block>
|
||||||
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.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||||
A league is your long-term brand. Each season is a block of races you can run again and again.
|
A league is your long-term brand. Each season is a block of races you can run again and again.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { SponsorHero } from '@/components/sponsors/SponsorHero';
|
|||||||
import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup';
|
import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup';
|
||||||
import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard';
|
import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard';
|
||||||
import { siteConfig } from '@/lib/siteConfig';
|
import { siteConfig } from '@/lib/siteConfig';
|
||||||
|
import { SponsorSignupCommandModel } from '@/lib/command-models/sponsors/SponsorSignupCommandModel';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -127,10 +128,8 @@ const PLATFORM_STATS = [
|
|||||||
export default function SponsorSignupPage() {
|
export default function SponsorSignupPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
|
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
|
||||||
|
const [form, setForm] = useState(() => new SponsorSignupCommandModel());
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
companyName: '',
|
|
||||||
contactEmail: '',
|
|
||||||
websiteUrl: '',
|
|
||||||
logoFile: null as File | null,
|
logoFile: null as File | null,
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
@@ -144,18 +143,9 @@ export default function SponsorSignupPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const newErrors: Record<string, string> = {};
|
const validationErrors = form.validate();
|
||||||
|
const newErrors: Record<string, string> = { ...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 (mode === 'signup') {
|
||||||
if (!formData.password.trim()) {
|
if (!formData.password.trim()) {
|
||||||
newErrors.password = 'Password required';
|
newErrors.password = 'Password required';
|
||||||
@@ -184,18 +174,19 @@ export default function SponsorSignupPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const command = form.toCommand();
|
||||||
// Note: Business logic for auth should be moved to a mutation
|
// Note: Business logic for auth should be moved to a mutation
|
||||||
// This is a temporary implementation for contract compliance
|
// This is a temporary implementation for contract compliance
|
||||||
const response = await fetch('/api/auth/signup', {
|
const response = await fetch('/api/auth/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: formData.contactEmail,
|
email: command.contactEmail,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
displayName: formData.companyName,
|
displayName: command.companyName,
|
||||||
sponsorData: {
|
sponsorData: {
|
||||||
companyName: formData.companyName,
|
companyName: command.companyName,
|
||||||
websiteUrl: formData.websiteUrl,
|
websiteUrl: command.websiteUrl,
|
||||||
interests: formData.interests,
|
interests: formData.interests,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -210,7 +201,7 @@ export default function SponsorSignupPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: formData.contactEmail,
|
email: command.contactEmail,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -468,8 +459,11 @@ export default function SponsorSignupPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contactEmail}
|
value={form.contactEmail}
|
||||||
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
|
onChange={(e) => {
|
||||||
|
form.contactEmail = e.target.value;
|
||||||
|
setForm(new SponsorSignupCommandModel(form.toCommand()));
|
||||||
|
}}
|
||||||
placeholder="sponsor@company.com"
|
placeholder="sponsor@company.com"
|
||||||
variant={errors.contactEmail ? 'error' : 'default'}
|
variant={errors.contactEmail ? 'error' : 'default'}
|
||||||
errorMessage={errors.contactEmail}
|
errorMessage={errors.contactEmail}
|
||||||
@@ -566,8 +560,11 @@ export default function SponsorSignupPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.companyName}
|
value={form.companyName}
|
||||||
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
form.companyName = e.target.value;
|
||||||
|
setForm(new SponsorSignupCommandModel(form.toCommand()));
|
||||||
|
}}
|
||||||
placeholder="Your company name"
|
placeholder="Your company name"
|
||||||
variant={errors.companyName ? 'error' : 'default'}
|
variant={errors.companyName ? 'error' : 'default'}
|
||||||
errorMessage={errors.companyName}
|
errorMessage={errors.companyName}
|
||||||
@@ -583,8 +580,11 @@ export default function SponsorSignupPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contactEmail}
|
value={form.contactEmail}
|
||||||
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
|
onChange={(e) => {
|
||||||
|
form.contactEmail = e.target.value;
|
||||||
|
setForm(new SponsorSignupCommandModel(form.toCommand()));
|
||||||
|
}}
|
||||||
placeholder="sponsor@company.com"
|
placeholder="sponsor@company.com"
|
||||||
variant={errors.contactEmail ? 'error' : 'default'}
|
variant={errors.contactEmail ? 'error' : 'default'}
|
||||||
errorMessage={errors.contactEmail}
|
errorMessage={errors.contactEmail}
|
||||||
@@ -600,9 +600,14 @@ export default function SponsorSignupPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.websiteUrl}
|
value={form.websiteUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, websiteUrl: e.target.value })}
|
onChange={(e) => {
|
||||||
|
form.websiteUrl = e.target.value;
|
||||||
|
setForm(new SponsorSignupCommandModel(form.toCommand()));
|
||||||
|
}}
|
||||||
placeholder="https://company.com"
|
placeholder="https://company.com"
|
||||||
|
variant={errors.websiteUrl ? 'error' : 'default'}
|
||||||
|
errorMessage={errors.websiteUrl}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -782,4 +787,4 @@ export default function SponsorSignupPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type LeagueWizardFormData = {
|
|||||||
raceTimeUtc?: string;
|
raceTimeUtc?: string;
|
||||||
};
|
};
|
||||||
stewarding: {
|
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;
|
requiredVotes?: number;
|
||||||
requireDefense: boolean;
|
requireDefense: boolean;
|
||||||
defenseTimeLimit: number;
|
defenseTimeLimit: number;
|
||||||
|
|||||||
@@ -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<RaceScheduleFormData> = {}) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SponsorSignupFormData> = {}) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,14 +250,23 @@ Minimal guardrails that pay off:
|
|||||||
|
|
||||||
- The “mess” is client-side writes bypassing this.
|
- 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)
|
- **Purpose**: They manage transient form state and perform client-side UX validation.
|
||||||
- There are dozens of hooks using it (search results in workspace).
|
- **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:
|
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.
|
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.
|
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`.
|
4. **ViewModel Builders**: Introduced `LeagueSummaryViewModelBuilder` to eliminate ad-hoc ViewData -> ViewModel mapping in `LeaguesPageClient.tsx`.
|
||||||
5. **React Query Deprecation**: Deprecated `usePageMutation` in `usePageData.ts`.
|
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.
|
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
|
## 8) Next Steps for the Team
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user