website refactor
This commit is contained in:
134
apps/website/app/profile/ProfilePageClient.tsx
Normal file
134
apps/website/app/profile/ProfilePageClient.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Input from '@/components/ui/Input';
|
||||
import TabNavigation from '@/components/ui/TabNavigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
|
||||
type ProfileTab = 'overview' | 'history' | 'stats';
|
||||
|
||||
type SaveError = string | null;
|
||||
|
||||
interface ProfilePageClientProps {
|
||||
viewData: ProfileViewData;
|
||||
mode: 'profile-exists' | 'needs-profile';
|
||||
onSaveSettings: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
|
||||
}
|
||||
|
||||
export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePageClientProps) {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview');
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [bio, setBio] = useState(viewData.driver.bio ?? '');
|
||||
const [countryCode, setCountryCode] = useState(viewData.driver.countryCode ?? '');
|
||||
const [saveError, setSaveError] = useState<SaveError>(null);
|
||||
|
||||
if (mode === 'needs-profile') {
|
||||
return (
|
||||
<Container size="md">
|
||||
<Heading level={1}>Create your driver profile</Heading>
|
||||
<Card>
|
||||
<p>Driver profile not found for this account.</p>
|
||||
<Link href={routes.protected.onboarding}>
|
||||
<Button variant="primary">Start onboarding</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<Container size="md">
|
||||
<Heading level={1}>Edit profile</Heading>
|
||||
|
||||
<Card>
|
||||
<Heading level={3}>Profile</Heading>
|
||||
|
||||
<Input
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="Bio"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value)}
|
||||
placeholder="Country code (e.g. DE)"
|
||||
/>
|
||||
|
||||
{saveError ? <p>{saveError}</p> : null}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
setSaveError(null);
|
||||
const result = await onSaveSettings({ bio, country: countryCode });
|
||||
if (result.isErr()) {
|
||||
setSaveError(result.getError());
|
||||
return;
|
||||
}
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={() => setEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg">
|
||||
<Heading level={1}>{viewData.driver.name || 'Profile'}</Heading>
|
||||
|
||||
<Button variant="primary" onClick={() => setEditMode(true)}>
|
||||
Edit profile
|
||||
</Button>
|
||||
|
||||
<TabNavigation
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'history', label: 'Race History' },
|
||||
{ id: 'stats', label: 'Detailed Stats' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tabId) => setActiveTab(tabId as ProfileTab)}
|
||||
/>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Driver</Heading>
|
||||
<p>{viewData.driver.countryCode}</p>
|
||||
<p>{viewData.driver.joinedAtLabel}</p>
|
||||
<p>{viewData.driver.bio ?? ''}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'history' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Race history</Heading>
|
||||
<p>Race history is currently unavailable in this view.</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'stats' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Stats</Heading>
|
||||
<p>{viewData.stats?.ratingLabel ?? ''}</p>
|
||||
<p>{viewData.stats?.globalRankLabel ?? ''}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
20
apps/website/app/profile/actions.ts
Normal file
20
apps/website/app/profile/actions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { UpdateDriverProfileMutation } from '@/lib/mutations/drivers/UpdateDriverProfileMutation';
|
||||
|
||||
export async function updateProfileAction(
|
||||
updates: { bio?: string; country?: string },
|
||||
): Promise<Result<void, string>> {
|
||||
const mutation = new UpdateDriverProfileMutation();
|
||||
const result = await mutation.execute({ bio: updates.bio, country: updates.country });
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
revalidatePath(routes.protected.profile);
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import ProfileLayoutShell from '@/components/profile/ProfileLayoutShell';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
|
||||
interface ProfileLayoutProps {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile Layout
|
||||
*
|
||||
* Provides authentication protection for all profile-related routes.
|
||||
* Uses RouteGuard to enforce access control server-side.
|
||||
*/
|
||||
export default async function ProfileLayout({ children }: ProfileLayoutProps) {
|
||||
const headerStore = await headers();
|
||||
const pathname = headerStore.get('x-pathname') || '/';
|
||||
|
||||
|
||||
const guard = createRouteGuard();
|
||||
await guard.enforce({ pathname });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const result = await guard.enforce({ pathname });
|
||||
if (result.type === 'redirect') {
|
||||
redirect(result.to);
|
||||
}
|
||||
|
||||
return <ProfileLayoutShell>{children}</ProfileLayoutShell>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient';
|
||||
|
||||
export default async function ProfileLeaguesPage() {
|
||||
|
||||
@@ -1,164 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Paintbrush, Upload, Car, Download, Trash2, Edit } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface DriverLiveryItem {
|
||||
id: string;
|
||||
carId: string;
|
||||
carName: string;
|
||||
thumbnailUrl: string;
|
||||
uploadedAt: Date;
|
||||
isValidated: boolean;
|
||||
}
|
||||
|
||||
export default function DriverLiveriesPage() {
|
||||
const [liveries] = useState<DriverLiveryItem[]>([]);
|
||||
|
||||
export default async function ProfileLiveriesPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Paintbrush className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">My Liveries</h1>
|
||||
<p className="text-sm text-gray-400">Manage your car liveries across leagues</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/profile/liveries/upload">
|
||||
<Button variant="primary">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Livery
|
||||
</Button>
|
||||
<Container size="md">
|
||||
<Heading level={1}>Liveries</Heading>
|
||||
<Card>
|
||||
<p>Livery management is currently unavailable.</p>
|
||||
<Link href={routes.protected.profile}>
|
||||
<Button variant="secondary">Back to profile</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Livery Collection */}
|
||||
{liveries.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<Car className="w-10 h-10 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-white mb-3">No Liveries Yet</h3>
|
||||
<p className="text-sm text-gray-400 max-w-md mx-auto mb-6">
|
||||
Upload your first livery. Use the same livery across multiple leagues or create custom ones for each.
|
||||
</p>
|
||||
<Link href="/profile/liveries/upload">
|
||||
<Button variant="primary">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Your First Livery
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{liveries.map((livery) => (
|
||||
<Card key={livery.id} className="overflow-hidden hover:border-primary-blue/50 transition-colors">
|
||||
{/* Livery Preview */}
|
||||
<div className="aspect-video bg-deep-graphite rounded-lg mb-4 flex items-center justify-center border border-charcoal-outline">
|
||||
<Car className="w-16 h-16 text-gray-600" />
|
||||
</div>
|
||||
|
||||
{/* Livery Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-white">{livery.carName}</h3>
|
||||
{livery.isValidated ? (
|
||||
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
|
||||
Validated
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-amber/10 text-warning-amber border border-warning-amber/30 rounded-full">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Uploaded {new Date(livery.uploadedAt).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="secondary" className="flex-1 px-3 py-1.5">
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" className="px-3 py-1.5">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="danger" className="px-3 py-1.5">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">Livery Requirements</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue mt-0.5">•</span>
|
||||
PNG or DDS format, max 5MB
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue mt-0.5">•</span>
|
||||
No logos or text allowed on base livery
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue mt-0.5">•</span>
|
||||
Sponsor decals are added by league admins
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue mt-0.5">•</span>
|
||||
Your driver name and number are added automatically
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">How It Works</h3>
|
||||
<ol className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue font-semibold">1.</span>
|
||||
Upload your base livery for each car you race
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue font-semibold">2.</span>
|
||||
Position your name and number decals
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue font-semibold">3.</span>
|
||||
League admins add sponsor logos
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue font-semibold">4.</span>
|
||||
Download the final pack with all decals burned in
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alpha Notice */}
|
||||
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong className="text-warning-amber">Alpha Note:</strong> Livery management is demonstration-only.
|
||||
In production, liveries are stored in cloud storage and composited with sponsor decals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={routes.protected.profileLiveryUpload}>
|
||||
<Button variant="primary">Upload livery</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Upload, Check, AlertTriangle, Car, RotateCw, Gamepad2 } from 'lucide-react';
|
||||
|
||||
interface DecalPosition {
|
||||
id: string;
|
||||
type: 'name' | 'number' | 'rank';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
interface GameOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CarOption {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
gameId: string;
|
||||
}
|
||||
|
||||
// Mock data - in production these would come from API
|
||||
const GAMES: GameOption[] = [
|
||||
{ id: 'iracing', name: 'iRacing' },
|
||||
{ id: 'acc', name: 'Assetto Corsa Competizione' },
|
||||
{ id: 'ac', name: 'Assetto Corsa' },
|
||||
{ id: 'rf2', name: 'rFactor 2' },
|
||||
{ id: 'ams2', name: 'Automobilista 2' },
|
||||
{ id: 'lmu', name: 'Le Mans Ultimate' },
|
||||
];
|
||||
|
||||
const CARS: CarOption[] = [
|
||||
// iRacing cars
|
||||
{ id: 'ir-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'iracing' },
|
||||
{ id: 'ir-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'iracing' },
|
||||
{ id: 'ir-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'iracing' },
|
||||
{ id: 'ir-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'iracing' },
|
||||
{ id: 'ir-audi-r8-gt3', name: 'R8 LMS GT3 Evo II', manufacturer: 'Audi', gameId: 'iracing' },
|
||||
{ id: 'ir-dallara-f3', name: 'F3', manufacturer: 'Dallara', gameId: 'iracing' },
|
||||
{ id: 'ir-dallara-ir18', name: 'IR-18', manufacturer: 'Dallara', gameId: 'iracing' },
|
||||
// ACC cars
|
||||
{ id: 'acc-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'acc' },
|
||||
{ id: 'acc-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'acc' },
|
||||
{ id: 'acc-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'acc' },
|
||||
{ id: 'acc-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'acc' },
|
||||
{ id: 'acc-lamborghini-huracan-gt3', name: 'Huracán GT3 Evo2', manufacturer: 'Lamborghini', gameId: 'acc' },
|
||||
{ id: 'acc-aston-martin-v8-gt3', name: 'V8 Vantage GT3', manufacturer: 'Aston Martin', gameId: 'acc' },
|
||||
// AC cars
|
||||
{ id: 'ac-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ac' },
|
||||
{ id: 'ac-ferrari-488-gt3', name: '488 GT3', manufacturer: 'Ferrari', gameId: 'ac' },
|
||||
{ id: 'ac-lotus-exos', name: 'Exos 125', manufacturer: 'Lotus', gameId: 'ac' },
|
||||
// rFactor 2 cars
|
||||
{ id: 'rf2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'rf2' },
|
||||
{ id: 'rf2-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'rf2' },
|
||||
// AMS2 cars
|
||||
{ id: 'ams2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ams2' },
|
||||
{ id: 'ams2-mclaren-720s-gt3', name: '720S GT3', manufacturer: 'McLaren', gameId: 'ams2' },
|
||||
// LMU cars
|
||||
{ id: 'lmu-porsche-963', name: '963 LMDh', manufacturer: 'Porsche', gameId: 'lmu' },
|
||||
{ id: 'lmu-ferrari-499p', name: '499P', manufacturer: 'Ferrari', gameId: 'lmu' },
|
||||
{ id: 'lmu-toyota-gr010', name: 'GR010', manufacturer: 'Toyota', gameId: 'lmu' },
|
||||
];
|
||||
|
||||
export default function LiveryUploadPage() {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [selectedGame, setSelectedGame] = useState<string>('');
|
||||
const [selectedCar, setSelectedCar] = useState<string>('');
|
||||
const [filteredCars, setFilteredCars] = useState<CarOption[]>([]);
|
||||
const [decals, setDecals] = useState<DecalPosition[]>([
|
||||
{ id: 'name', type: 'name', x: 0.1, y: 0.8, width: 0.2, height: 0.05, rotation: 0 },
|
||||
{ id: 'number', type: 'number', x: 0.8, y: 0.1, width: 0.15, height: 0.15, rotation: 0 },
|
||||
{ id: 'rank', type: 'rank', x: 0.05, y: 0.1, width: 0.1, height: 0.1, rotation: 0 },
|
||||
]);
|
||||
const [activeDecal, setActiveDecal] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Filter cars when game changes
|
||||
useEffect(() => {
|
||||
if (selectedGame) {
|
||||
const cars = CARS.filter(car => car.gameId === selectedGame);
|
||||
setFilteredCars(cars);
|
||||
setSelectedCar(''); // Reset car selection when game changes
|
||||
} else {
|
||||
setFilteredCars([]);
|
||||
setSelectedCar('');
|
||||
}
|
||||
}, [selectedGame]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadedFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
setUploadedFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uploadedFile || !selectedGame || !selectedCar) return;
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Alpha: In-memory only
|
||||
console.log('Livery upload:', {
|
||||
file: uploadedFile.name,
|
||||
gameId: selectedGame,
|
||||
carId: selectedCar,
|
||||
decals,
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
alert('Livery uploaded successfully.');
|
||||
router.push('/profile/liveries');
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
alert('Upload failed. Try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileLiveryUploadPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Upload className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Upload Livery</h1>
|
||||
<p className="text-sm text-gray-400">Add a new livery to your collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Section */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Livery File</h2>
|
||||
|
||||
{/* Game Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gamepad2 className="w-4 h-4" />
|
||||
Select Game
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={selectedGame}
|
||||
onChange={(e) => setSelectedGame(e.target.value)}
|
||||
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="">Choose a game...</option>
|
||||
{GAMES.map((game) => (
|
||||
<option key={game.id} value={game.id}>
|
||||
{game.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Car Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="w-4 h-4" />
|
||||
Select Car
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={selectedCar}
|
||||
onChange={(e) => setSelectedCar(e.target.value)}
|
||||
disabled={!selectedGame}
|
||||
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">{selectedGame ? 'Choose a car...' : 'Select a game first...'}</option>
|
||||
{filteredCars.map((car) => (
|
||||
<option key={car.id} value={car.id}>
|
||||
{car.manufacturer} {car.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedGame && filteredCars.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">No cars available for this game</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
previewUrl
|
||||
? 'border-performance-green/50 bg-performance-green/5'
|
||||
: 'border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.dds"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="space-y-3">
|
||||
<Check className="w-12 h-12 text-performance-green mx-auto" />
|
||||
<p className="text-sm text-white font-medium">{uploadedFile?.name}</p>
|
||||
<p className="text-xs text-gray-500">Click to replace</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-gray-500 mx-auto" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Drop your livery here or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">PNG or DDS, max 5MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Warning */}
|
||||
<div className="mt-4 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning-amber shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong className="text-warning-amber">No logos or text allowed.</strong>{' '}
|
||||
Your base livery must be clean. Sponsor logos are added by league admins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decal Editor */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Position Decals</h2>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Drag to position your driver name, number, and rank badge.
|
||||
</p>
|
||||
|
||||
{/* Preview Canvas */}
|
||||
<div className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden mb-4">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Livery preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Car className="w-20 h-20 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decal Placeholders */}
|
||||
{decals.map((decal) => (
|
||||
<div
|
||||
key={decal.id}
|
||||
onClick={() => setActiveDecal(decal.id === activeDecal ? null : decal.id)}
|
||||
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${
|
||||
activeDecal === decal.id
|
||||
? 'border-primary-blue bg-primary-blue/20 text-primary-blue'
|
||||
: 'border-white/30 bg-black/30 text-white/70'
|
||||
}`}
|
||||
style={{
|
||||
left: `${decal.x * 100}%`,
|
||||
top: `${decal.y * 100}%`,
|
||||
width: `${decal.width * 100}%`,
|
||||
height: `${decal.height * 100}%`,
|
||||
transform: `rotate(${decal.rotation}deg)`,
|
||||
}}
|
||||
>
|
||||
{decal.type === 'name' && 'NAME'}
|
||||
{decal.type === 'number' && '#'}
|
||||
{decal.type === 'rank' && 'RANK'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Decal Controls */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{decals.map((decal) => (
|
||||
<button
|
||||
key={decal.id}
|
||||
onClick={() => setActiveDecal(decal.id === activeDecal ? null : decal.id)}
|
||||
className={`p-3 rounded-lg border text-center transition-all ${
|
||||
activeDecal === decal.id
|
||||
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
|
||||
: 'border-charcoal-outline bg-iron-gray/30 text-gray-400 hover:border-primary-blue/50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium capitalize mb-1">{decal.type}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{Math.round(decal.x * 100)}%, {Math.round(decal.y * 100)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{decal.rotation}°
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rotation Controls */}
|
||||
{activeDecal && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-300 capitalize">
|
||||
{decals.find(d => d.id === activeDecal)?.type} Rotation
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{decals.find(d => d.id === activeDecal)?.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="15"
|
||||
value={decals.find(d => d.id === activeDecal)?.rotation ?? 0}
|
||||
onChange={(e) => {
|
||||
const rotation = parseInt(e.target.value, 10);
|
||||
setDecals(decals.map(d =>
|
||||
d.id === activeDecal ? { ...d, rotation } : d
|
||||
));
|
||||
}}
|
||||
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDecals(decals.map(d =>
|
||||
d.id === activeDecal ? { ...d, rotation: (d.rotation + 90) % 360 } : d
|
||||
));
|
||||
}}
|
||||
className="p-2 rounded-lg border border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors"
|
||||
title="Rotate 90°"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Click a decal above, then drag on preview to reposition. Use the slider or button to rotate.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!uploadedFile || !selectedGame || !selectedCar || submitting}
|
||||
>
|
||||
{submitting ? 'Uploading...' : 'Upload Livery'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.back()}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alpha Notice */}
|
||||
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong className="text-warning-amber">Alpha Note:</strong> Livery upload is demonstration-only.
|
||||
Decal positioning and image validation are not functional in this preview.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="md">
|
||||
<Heading level={1}>Upload livery</Heading>
|
||||
<Card>
|
||||
<p>Livery upload is currently unavailable.</p>
|
||||
<Link href={routes.protected.profileLiveries}>
|
||||
<Button variant="secondary">Back to liveries</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,226 +1,20 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
import { Bell, Shield, Eye, Volume2 } from 'lucide-react';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
|
||||
interface SettingsData {
|
||||
// Settings page is static, no data needed
|
||||
}
|
||||
|
||||
function SettingsTemplate({ data }: { data: SettingsData }) {
|
||||
export default async function ProfileSettingsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-8">Settings</h1>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Notification Settings */}
|
||||
<section className="bg-iron-gray rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Bell className="h-5 w-5 text-primary-blue" />
|
||||
<h2 className="text-lg font-semibold text-white">Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Protest Filed Against You</p>
|
||||
<p className="text-xs text-gray-400">Get notified when someone files a protest involving you</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
<option value="toast">Toast (Popup)</option>
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Vote Requested</p>
|
||||
<p className="text-xs text-gray-400">Get notified when your vote is needed on a protest</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
<option value="toast">Toast (Popup)</option>
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Defense Required</p>
|
||||
<p className="text-xs text-gray-400">Get notified when you need to submit a defense</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
<option value="toast">Toast (Popup)</option>
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Penalty Issued</p>
|
||||
<p className="text-xs text-gray-400">Get notified when you receive a penalty</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="toast" selected>Toast (Popup)</option>
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Race Starting Soon</p>
|
||||
<p className="text-xs text-gray-400">Reminder before scheduled races begin</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="toast">Toast (Popup)</option>
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">League Announcements</p>
|
||||
<p className="text-xs text-gray-400">Updates from league administrators</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="silent">Silent (Notification Center)</option>
|
||||
<option value="toast">Toast (Popup)</option>
|
||||
<option value="modal">Modal (Blocking)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Display Settings */}
|
||||
<section className="bg-iron-gray rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Eye className="h-5 w-5 text-primary-blue" />
|
||||
<h2 className="text-lg font-semibold text-white">Display</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Toast Duration</p>
|
||||
<p className="text-xs text-gray-400">How long toast notifications stay visible</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="3000">3 seconds</option>
|
||||
<option value="5000" selected>5 seconds</option>
|
||||
<option value="8000">8 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Toast Position</p>
|
||||
<p className="text-xs text-gray-400">Where toast notifications appear on screen</p>
|
||||
</div>
|
||||
<select className="bg-deep-graphite border border-charcoal-outline rounded px-3 py-1.5 text-sm text-white">
|
||||
<option value="top-right">Top Right</option>
|
||||
<option value="top-left">Top Left</option>
|
||||
<option value="bottom-right" selected>Bottom Right</option>
|
||||
<option value="bottom-left">Bottom Left</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sound Settings */}
|
||||
<section className="bg-iron-gray rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Volume2 className="h-5 w-5 text-primary-blue" />
|
||||
<h2 className="text-lg font-semibold text-white">Sound</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Notification Sounds</p>
|
||||
<p className="text-xs text-gray-400">Play sounds for new notifications</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-charcoal-outline peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-blue"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Urgent Notification Sound</p>
|
||||
<p className="text-xs text-gray-400">Special sound for modal notifications</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-charcoal-outline peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-blue"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy Settings */}
|
||||
<section className="bg-iron-gray rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="h-5 w-5 text-primary-blue" />
|
||||
<h2 className="text-lg font-semibold text-white">Privacy</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Show Online Status</p>
|
||||
<p className="text-xs text-gray-400">Let others see when you're online</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-charcoal-outline peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-blue"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Public Profile</p>
|
||||
<p className="text-xs text-gray-400">Allow non-league members to view your profile</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-charcoal-outline peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-blue"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button className="px-6 py-2 bg-primary-blue text-white font-medium rounded-lg hover:bg-primary-blue/90 transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="md">
|
||||
<Heading level={1}>Settings</Heading>
|
||||
<Card>
|
||||
<p>Settings are currently unavailable.</p>
|
||||
<Link href={routes.protected.profile}>
|
||||
<Button variant="secondary">Back to profile</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={{} as SettingsData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
Template={SettingsTemplate}
|
||||
loading={{ variant: 'skeleton', message: 'Loading settings...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
title: 'No settings available',
|
||||
description: 'Unable to load settings page',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
|
||||
interface SponsorshipRequestsPageClientProps {
|
||||
viewData: SponsorshipRequestsViewData;
|
||||
onAccept: (requestId: string) => Promise<Result<void, string>>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestsPageClient({ viewData, onAccept, onReject }: SponsorshipRequestsPageClientProps) {
|
||||
return (
|
||||
<SponsorshipRequestsTemplate
|
||||
data={viewData.sections}
|
||||
onAccept={async (requestId) => {
|
||||
await onAccept(requestId);
|
||||
}}
|
||||
onReject={async (requestId, reason) => {
|
||||
await onReject(requestId, reason);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
apps/website/app/profile/sponsorship-requests/actions.ts
Normal file
26
apps/website/app/profile/sponsorship-requests/actions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
|
||||
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
|
||||
import type { AcceptSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { RejectSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
|
||||
export async function acceptSponsorshipRequest(
|
||||
command: AcceptSponsorshipRequestCommand,
|
||||
): Promise<void> {
|
||||
const mutation = new AcceptSponsorshipRequestMutation();
|
||||
const result = await mutation.execute(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to accept sponsorship request');
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectSponsorshipRequest(
|
||||
command: RejectSponsorshipRequestCommand,
|
||||
): Promise<void> {
|
||||
const mutation = new RejectSponsorshipRequestMutation();
|
||||
const result = await mutation.execute(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to reject sponsorship request');
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
import {
|
||||
useSponsorshipRequestsPageData,
|
||||
useSponsorshipRequestMutations
|
||||
} from "@/lib/hooks/sponsor/useSponsorshipRequestsPageData";
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
|
||||
export default function SponsorshipRequestsPage() {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Fetch data using domain hook
|
||||
const { data: sections, isLoading, error, refetch } = useSponsorshipRequestsPageData(currentDriverId);
|
||||
|
||||
// Mutations using domain hook
|
||||
const { acceptMutation, rejectMutation } = useSponsorshipRequestMutations(currentDriverId, refetch);
|
||||
|
||||
// Template needs to handle mutations
|
||||
const TemplateWithMutations = ({ data }: { data: any[] }) => (
|
||||
<SponsorshipRequestsTemplate
|
||||
data={data}
|
||||
onAccept={async (requestId) => {
|
||||
await acceptMutation.mutateAsync({ requestId });
|
||||
}}
|
||||
onReject={async (requestId, reason) => {
|
||||
await rejectMutation.mutateAsync({ requestId, reason });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={sections}
|
||||
isLoading={isLoading}
|
||||
error={error as Error | null}
|
||||
retry={refetch}
|
||||
Template={TemplateWithMutations}
|
||||
loading={{ variant: 'skeleton', message: 'Loading sponsorship requests...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
title: 'No Pending Requests',
|
||||
description: 'You don\'t have any pending sponsorship requests at the moment.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default async function SponsorshipRequestsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string>;
|
||||
}) {
|
||||
return <SponsorshipRequestsTemplate searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user