alpha wip

This commit is contained in:
2025-12-03 00:46:08 +01:00
parent 3b55fd1a63
commit 97e29d3d80
51 changed files with 6321 additions and 237 deletions

View File

@@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
export default function AlphaBanner() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('alpha-banner-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('alpha-banner-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="sticky top-0 z-50 bg-warning-amber/10 border-b border-warning-amber/20 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-warning-amber flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-white">
Alpha Version Data resets on page reload. No persistent storage.
</p>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
export default function AlphaFooter() {
return (
<footer className="mt-auto border-t border-charcoal-outline bg-deep-graphite">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-2 py-1 bg-warning-amber/10 text-warning-amber rounded border border-warning-amber/20 font-medium">
Alpha v0.1
</span>
<span>In-memory prototype</span>
</div>
<div className="flex items-center gap-6 text-sm">
<a
href="https://discord.gg/gridpilot"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Give Feedback
</a>
<a
href="/docs/roadmap"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Roadmap
</a>
<a
href="/"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Back to Landing
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navLinks = [
{ href: '/', label: 'Dashboard' },
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/races', label: 'Races' },
] as const;
export function AlphaNav() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-40 bg-deep-graphite/95 backdrop-blur-md border-b border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-14">
<div className="flex items-baseline space-x-3">
<Link href="/" className="text-xl font-semibold text-white hover:text-primary-blue transition-colors">
GridPilot
</Link>
<span className="text-xs text-gray-500 font-light">ALPHA</span>
</div>
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => {
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`
relative px-4 py-2 text-sm font-medium transition-all duration-200
${isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{link.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue rounded-full" />
)}
</Link>
);
})}
</div>
<div className="md:hidden w-8" />
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Race } from '../../domain/entities/Race';
interface CompanionInstructionsProps {
race: Race;
leagueName?: string;
}
export default function CompanionInstructions({ race, leagueName }: CompanionInstructionsProps) {
const [copied, setCopied] = useState(false);
const formatDateTime = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
Track: ${race.track}
Car: ${race.car}
Date/Time: ${formatDateTime(race.scheduledAt)}
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
const handleCopyDetails = async () => {
try {
await navigator.clipboard.writeText(raceDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<Card className="border border-primary-blue/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">Alpha Manual Workflow</h3>
<p className="text-sm text-gray-400">
Companion automation coming in production. For alpha, races are created manually.
</p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary-blue/20 text-primary-blue text-xs font-semibold flex-shrink-0">
1
</span>
<p className="text-sm text-gray-300 pt-0.5">
Schedule race in GridPilot (completed)
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
2
</span>
<p className="text-sm text-gray-300 pt-0.5">
Copy race details using button below
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
3
</span>
<p className="text-sm text-gray-300 pt-0.5">
Create hosted session manually in iRacing website
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
4
</span>
<p className="text-sm text-gray-300 pt-0.5">
Return to GridPilot after race completes
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
5
</span>
<p className="text-sm text-gray-300 pt-0.5">
Import results via CSV upload
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<div className="bg-deep-graphite rounded-lg p-3 mb-3">
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
{raceDetails}
</pre>
</div>
<Button
variant="primary"
onClick={handleCopyDetails}
className="w-full"
>
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{copied ? 'Copied!' : 'Copy Race Details'}
</div>
</Button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
interface CompanionStatusProps {
className?: string;
}
export default function CompanionStatus({ className = '' }: CompanionStatusProps) {
// Alpha: always disconnected
const isConnected = false;
const statusMessage = "Companion app available in production";
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-performance-green' : 'bg-gray-500'}`} />
<span className="text-sm text-gray-400">
Companion App: <span className={isConnected ? 'text-performance-green' : 'text-gray-400'}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</span>
</div>
<span className="text-xs text-gray-500">
{statusMessage}
</span>
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Driver } from '../../domain/entities/Driver';
import { getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
iracingId?: string;
country?: string;
bio?: string;
submit?: string;
}
export default function CreateDriverForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
iracingId: '',
country: '',
bio: ''
});
const validateForm = async (): Promise<boolean> => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.iracingId.trim()) {
newErrors.iracingId = 'iRacing ID is required';
} else {
const driverRepo = getDriverRepository();
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
if (exists) {
newErrors.iracingId = 'This iRacing ID is already registered';
}
}
if (!formData.country.trim()) {
newErrors.country = 'Country is required';
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
newErrors.country = 'Invalid country code (use 2-3 letter ISO code)';
}
if (formData.bio && formData.bio.length > 500) {
newErrors.bio = 'Bio must be 500 characters or less';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
const isValid = await validateForm();
if (!isValid) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const driver = Driver.create({
id: crypto.randomUUID(),
iracingId: formData.iracingId.trim(),
name: formData.name.trim(),
country: formData.country.trim().toUpperCase(),
bio: formData.bio.trim() || undefined,
});
await driverRepo.create(driver);
router.push('/profile');
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Driver Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="Max Verstappen"
disabled={loading}
/>
</div>
<div>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
iRacing ID *
</label>
<Input
id="iracingId"
type="text"
value={formData.iracingId}
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
error={!!errors.iracingId}
errorMessage={errors.iracingId}
placeholder="123456"
disabled={loading}
/>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country Code *
</label>
<Input
id="country"
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
error={!!errors.country}
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
Bio (Optional)
</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.bio.length}/500
</p>
{errors.bio && (
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
)}
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { League } from '../../domain/entities/League';
import { getLeagueRepository, getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
description?: string;
pointsSystem?: string;
sessionDuration?: string;
submit?: string;
}
export default function CreateLeagueForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
description: '',
pointsSystem: 'f1-2024' as 'f1-2024' | 'indycar',
sessionDuration: 60
});
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
} else if (formData.name.length > 100) {
newErrors.name = 'Name must be 100 characters or less';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length > 500) {
newErrors.description = 'Description must be 500 characters or less';
}
if (formData.sessionDuration < 1 || formData.sessionDuration > 240) {
newErrors.sessionDuration = 'Session duration must be between 1 and 240 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
if (!validateForm()) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
setLoading(false);
return;
}
const leagueRepo = getLeagueRepository();
const league = League.create({
id: crypto.randomUUID(),
name: formData.name.trim(),
description: formData.description.trim(),
ownerId: currentDriver.id,
settings: {
pointsSystem: formData.pointsSystem,
sessionDuration: formData.sessionDuration,
qualifyingFormat: 'open',
},
});
await leagueRepo.create(league);
router.push(`/leagues/${league.id}`);
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create league'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
League Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="European GT Championship"
maxLength={100}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.name.length}/100
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Weekly GT3 racing with professional drivers"
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.description.length}/500
</p>
{errors.description && (
<p className="mt-2 text-sm text-warning-amber">{errors.description}</p>
)}
</div>
<div>
<label htmlFor="pointsSystem" className="block text-sm font-medium text-gray-300 mb-2">
Points System *
</label>
<select
id="pointsSystem"
value={formData.pointsSystem}
onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
>
<option value="f1-2024">F1 2024</option>
<option value="indycar">IndyCar</option>
</select>
</div>
<div>
<label htmlFor="sessionDuration" className="block text-sm font-medium text-gray-300 mb-2">
Session Duration (minutes) *
</label>
<Input
id="sessionDuration"
type="number"
value={formData.sessionDuration}
onChange={(e) => setFormData({ ...formData, sessionDuration: parseInt(e.target.value) || 60 })}
error={!!errors.sessionDuration}
errorMessage={errors.sessionDuration}
min={1}
max={240}
disabled={loading}
/>
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating League...' : 'Create League'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
export default function DataWarning() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('data-warning-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('data-warning-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="mb-6 bg-iron-gray border border-charcoal-outline rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-300">
Your data will be lost when you refresh the page. Alpha uses in-memory storage only.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1 flex-shrink-0"
aria-label="Dismiss warning"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { DriverDTO } from '@/application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
interface DriverProfileProps {
driver: DriverDTO;
}
export default function DriverProfile({ driver }: DriverProfileProps) {
const formattedDate = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(driver.joinedAt));
return (
<Card className="max-w-2xl mx-auto">
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{driver.name}</h2>
<p className="text-gray-400 text-sm">iRacing ID: {driver.iracingId}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
</div>
{driver.bio && (
<div className="border-t border-charcoal-outline pt-4">
<h3 className="text-sm font-semibold text-gray-400 mb-2">Bio</h3>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
</div>
)}
<div className="border-t border-charcoal-outline pt-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Member since {formattedDate}</span>
</div>
</div>
<div className="pt-4">
<Button
variant="secondary"
className="w-full"
disabled
>
Edit Profile
</Button>
<p className="text-xs text-gray-500 text-center mt-2">
Profile editing coming soon
</p>
</div>
</div>
</Card>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
}

View File

@@ -0,0 +1,23 @@
'use client';
interface FeatureLimitationTooltipProps {
message: string;
children: React.ReactNode;
}
export default function FeatureLimitationTooltip({ message, children }: FeatureLimitationTooltipProps) {
return (
<div className="group relative inline-block">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-sm text-gray-300 whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none z-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary-blue flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-iron-gray" />
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Result } from '../../domain/entities/Result';
import { v4 as uuidv4 } from 'uuid';
interface ImportResultsFormProps {
raceId: string;
onSuccess: (results: Result[]) => void;
onError: (error: string) => void;
}
interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const parseCSV = (content: string): CSVRow[] => {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
// Parse header
const header = lines[0].toLowerCase().split(',').map(h => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Parse rows
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
if (values.length !== header.length) {
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
}
const row: any = {};
header.forEach((field, index) => {
row[field] = values[index];
});
// Validate and convert types
const driverId = row.driverid;
const position = parseInt(row.position, 10);
const fastestLap = parseFloat(row.fastestlap);
const incidents = parseInt(row.incidents, 10);
const startPosition = parseInt(row.startposition, 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
// Validate no duplicate positions
const positions = rows.map(r => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
// Validate no duplicate drivers
const driverIds = rows.map(r => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
// Read file
const content = await file.text();
// Parse CSV
const rows = parseCSV(content);
// Create Result entities
const results = rows.map(row =>
Result.create({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
})
);
onSuccess(results);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to parse CSV file';
setError(errorMessage);
onError(errorMessage);
} finally {
setUploading(false);
// Reset file input
event.target.value = '';
}
};
return (
<>
<DataWarning />
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Upload Results CSV
</label>
<p className="text-xs text-gray-500 mb-3">
CSV format: driverId, position, fastestLap, incidents, startPosition
</p>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
disabled={uploading}
className="block w-full text-sm text-gray-400
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-semibold
file:bg-primary-blue file:text-white
file:cursor-pointer file:transition-colors
hover:file:bg-primary-blue/80
disabled:file:opacity-50 disabled:file:cursor-not-allowed"
/>
</div>
{error && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber text-sm">
<strong>Error:</strong> {error}
</div>
)}
{uploading && (
<div className="text-center text-gray-400 text-sm">
Parsing CSV and importing results...
</div>
)}
<div className="p-4 bg-iron-gray/20 rounded text-xs text-gray-500">
<p className="font-semibold mb-2">CSV Example:</p>
<pre className="text-gray-400">
{`driverId,position,fastestLap,incidents,startPosition
550e8400-e29b-41d4-a716-446655440001,1,92.456,0,3
550e8400-e29b-41d4-a716-446655440002,2,92.789,1,1
550e8400-e29b-41d4-a716-446655440003,3,93.012,2,2`}
</pre>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { League } from '../../domain/entities/League';
import Card from '../ui/Card';
interface LeagueCardProps {
league: League;
onClick?: () => void;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
onClick={onClick}
>
<Card>
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
<span className="text-xs text-gray-500">
{new Date(league.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</p>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="text-xs text-gray-500">
Owner ID: {league.ownerId.slice(0, 8)}...
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Race } from '../../domain/entities/Race';
interface RaceCardProps {
race: Race;
leagueName?: string;
onClick?: () => void;
}
export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
const statusColors = {
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const getRelativeTime = (date: Date) => {
const now = new Date();
const targetDate = new Date(date);
const diffMs = targetDate.getTime() - now.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) return null;
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `in ${diffDays} days`;
return null;
};
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
return (
<div
onClick={onClick}
className={`
p-6 rounded-lg bg-iron-gray border border-charcoal-outline
transition-all duration-200
${onClick ? 'cursor-pointer hover:scale-[1.03] hover:border-primary-blue' : ''}
`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white">{race.track}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded border ${statusColors[race.status]}`}>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
<p className="text-gray-400 text-sm">{race.car}</p>
{leagueName && (
<p className="text-gray-500 text-xs mt-1">{leagueName}</p>
)}
</div>
<div className="text-right">
<p className="text-white font-medium text-sm">{formatDate(race.scheduledAt)}</p>
<p className="text-gray-400 text-xs">{formatTime(race.scheduledAt)}</p>
{relativeTime && (
<p className="text-primary-blue text-xs mt-1">{relativeTime}</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 uppercase tracking-wide">
{race.sessionType}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { Result } from '../../domain/entities/Result';
import { Driver } from '../../domain/entities/Driver';
interface ResultsTableProps {
results: Result[];
drivers: Driver[];
pointsSystem: Record<number, number>;
fastestLapTime?: number;
}
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime }: ResultsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
const formatLapTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(3);
return `${minutes}:${secs.padStart(6, '0')}`;
};
const getPoints = (position: number): number => {
return pointsSystem[position] || 0;
};
const getPositionChangeColor = (change: number): string => {
if (change > 0) return 'text-performance-green';
if (change < 0) return 'text-warning-amber';
return 'text-gray-500';
};
const getPositionChangeText = (change: number): string => {
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
};
if (results.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No results available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Fastest Lap</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Incidents</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
const positionChange = result.getPositionChange();
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
return (
<tr
key={result.id}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className="text-white font-semibold">{result.position}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{getDriverName(result.driverId)}</span>
</td>
<td className="py-3 px-4">
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
{formatLapTime(result.fastestLap)}
</span>
</td>
<td className="py-3 px-4">
<span className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
{result.incidents}×
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{getPoints(result.position)}</span>
</td>
<td className="py-3 px-4">
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
{getPositionChangeText(positionChange)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import DataWarning from './DataWarning';
import { Race } from '../../domain/entities/Race';
import { League } from '../../domain/entities/League';
import { SessionType } from '../../domain/entities/Race';
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
import { InMemoryRaceRepository } from '../../infrastructure/repositories/InMemoryRaceRepository';
interface ScheduleRaceFormProps {
preSelectedLeagueId?: string;
onSuccess?: (race: Race) => void;
onCancel?: () => void;
}
export default function ScheduleRaceForm({
preSelectedLeagueId,
onSuccess,
onCancel
}: ScheduleRaceFormProps) {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
leagueId: preSelectedLeagueId || '',
track: '',
car: '',
sessionType: 'race' as SessionType,
scheduledDate: '',
scheduledTime: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadLeagues = async () => {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
setLeagues(allLeagues);
};
loadLeagues();
}, []);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.leagueId) {
errors.leagueId = 'League is required';
}
if (!formData.track.trim()) {
errors.track = 'Track is required';
}
if (!formData.car.trim()) {
errors.car = 'Car is required';
}
if (!formData.scheduledDate) {
errors.scheduledDate = 'Date is required';
}
if (!formData.scheduledTime) {
errors.scheduledTime = 'Time is required';
}
// Validate future date
if (formData.scheduledDate && formData.scheduledTime) {
const scheduledDateTime = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const now = new Date();
if (scheduledDateTime <= now) {
errors.scheduledDate = 'Date must be in the future';
}
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
setError(null);
try {
const raceRepo = getRaceRepository();
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const race = Race.create({
id: InMemoryRaceRepository.generateId(),
leagueId: formData.leagueId,
track: formData.track.trim(),
car: formData.car.trim(),
sessionType: formData.sessionType,
scheduledAt,
status: 'scheduled',
});
const createdRace = await raceRepo.create(race);
if (onSuccess) {
onSuccess(createdRace);
} else {
router.push(`/races/${createdRace.id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create race');
} finally {
setLoading(false);
}
};
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear validation error for this field
if (validationErrors[field]) {
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
</div>
)}
{/* Companion App Notice */}
<div className="p-4 rounded-lg bg-iron-gray border border-charcoal-outline">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-1">
<input
type="checkbox"
disabled
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue opacity-50 cursor-not-allowed"
/>
<label className="text-sm text-gray-400">
Use Companion App
</label>
<button
type="button"
className="text-gray-500 hover:text-gray-400 transition-colors"
title="Companion automation available in production. For alpha, races are created manually."
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2 ml-6">
Companion automation available in production. For alpha, races are created manually.
</p>
</div>
{/* League Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League *
</label>
<select
value={formData.leagueId}
onChange={(e) => handleChange('leagueId', e.target.value)}
disabled={!!preSelectedLeagueId}
className={`
w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white
focus:outline-none focus:ring-2 focus:ring-primary-blue
disabled:opacity-50 disabled:cursor-not-allowed
${validationErrors.leagueId ? 'border-red-500' : 'border-charcoal-outline'}
`}
>
<option value="">Select a league</option>
{leagues.map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
{validationErrors.leagueId && (
<p className="mt-1 text-sm text-red-400">{validationErrors.leagueId}</p>
)}
</div>
{/* Track */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Track *
</label>
<Input
type="text"
value={formData.track}
onChange={(e) => handleChange('track', e.target.value)}
placeholder="e.g., Spa-Francorchamps"
className={validationErrors.track ? 'border-red-500' : ''}
/>
{validationErrors.track && (
<p className="mt-1 text-sm text-red-400">{validationErrors.track}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing track name</p>
</div>
{/* Car */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Car *
</label>
<Input
type="text"
value={formData.car}
onChange={(e) => handleChange('car', e.target.value)}
placeholder="e.g., Porsche 911 GT3 R"
className={validationErrors.car ? 'border-red-500' : ''}
/>
{validationErrors.car && (
<p className="mt-1 text-sm text-red-400">{validationErrors.car}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing car name</p>
</div>
{/* Session Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Session Type *
</label>
<select
value={formData.sessionType}
onChange={(e) => handleChange('sessionType', e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="practice">Practice</option>
<option value="qualifying">Qualifying</option>
<option value="race">Race</option>
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Date *
</label>
<Input
type="date"
value={formData.scheduledDate}
onChange={(e) => handleChange('scheduledDate', e.target.value)}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time *
</label>
<Input
type="time"
value={formData.scheduledTime}
onChange={(e) => handleChange('scheduledTime', e.target.value)}
className={validationErrors.scheduledTime ? 'border-red-500' : ''}
/>
{validationErrors.scheduledTime && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledTime}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex-1"
>
{loading ? 'Creating...' : 'Schedule Race'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</form>
</>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { Standing } from '../../domain/entities/Standing';
import { Driver } from '../../domain/entities/Driver';
interface StandingsTableProps {
standings: Standing[];
drivers: Driver[];
}
export default function StandingsTable({ standings, drivers }: StandingsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
if (standings.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No standings available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Races</th>
</tr>
</thead>
<tbody>
{standings.map((standing) => {
const isLeader = standing.position === 1;
return (
<tr
key={`${standing.leagueId}-${standing.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{standing.position}
</span>
</td>
<td className="py-3 px-4">
<span className={isLeader ? 'text-white font-semibold' : 'text-white'}>
{getDriverName(standing.driverId)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{standing.points}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.wins}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.racesCompleted}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,21 +1,21 @@
'use client';
import { ReactNode } from 'react';
import { useState, useEffect, ReactNode } from 'react';
/**
* ModeGuard - Conditional rendering component based on application mode
*
*
* Usage:
* <ModeGuard mode="pre-launch">
* <PreLaunchContent />
* </ModeGuard>
*
* <ModeGuard mode="post-launch">
*
* <ModeGuard mode="alpha">
* <FullPlatformContent />
* </ModeGuard>
*/
export type GuardMode = 'pre-launch' | 'post-launch';
export type GuardMode = 'pre-launch' | 'alpha';
interface ModeGuardProps {
mode: GuardMode;
@@ -29,13 +29,23 @@ interface ModeGuardProps {
* This component is for conditional UI rendering within accessible pages
*/
export function ModeGuard({ mode, children, fallback = null }: ModeGuardProps) {
const currentMode = getClientMode();
if (currentMode === mode) {
return <>{children}</>;
const [isMounted, setIsMounted] = useState(false);
const [currentMode, setCurrentMode] = useState<GuardMode>('pre-launch');
useEffect(() => {
setIsMounted(true);
setCurrentMode(getClientMode());
}, []);
if (!isMounted) {
return <>{fallback}</>;
}
return <>{fallback}</>;
if (currentMode !== mode) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
@@ -49,8 +59,8 @@ function getClientMode(): GuardMode {
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (mode === 'post-launch') {
return 'post-launch';
if (mode === 'alpha') {
return 'alpha';
}
return 'pre-launch';
@@ -71,8 +81,8 @@ export function useIsPreLaunch(): boolean {
}
/**
* Hook to check if in post-launch mode
* Hook to check if in alpha mode
*/
export function useIsPostLaunch(): boolean {
return getClientMode() === 'post-launch';
export function useIsAlpha(): boolean {
return getClientMode() === 'alpha';
}