website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -2,23 +2,21 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProfileTemplate, type ProfileTab } from '@/templates/ProfileTemplate';
import { ProfileTemplate } from '@/templates/ProfileTemplate';
import { type ProfileTab } from '@/components/profile/ProfileNavTabs';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import type { Result } from '@/lib/contracts/Result';
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) {
export function ProfilePageClient({ viewData, mode }: ProfilePageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [editMode, setEditMode] = useState(false);
const [friendRequestSent, setFriendRequestSent] = useState(false);
useEffect(() => {
@@ -49,19 +47,8 @@ export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePag
mode={mode}
activeTab={activeTab}
onTabChange={setActiveTab}
editMode={editMode}
onEditModeChange={setEditMode}
friendRequestSent={friendRequestSent}
onFriendRequestSend={() => setFriendRequestSent(true)}
onSaveSettings={async (updates) => {
const result = await onSaveSettings(updates);
if (result.isErr()) {
// In a real app, we'd show a toast or error message.
// For now, we just throw to let the UI handle it if needed,
// or we could add an error state to this client component.
throw new Error(result.getError());
}
}}
/>
);
}

View File

@@ -1,20 +0,0 @@
'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);
}

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { ProfileLayoutShell } from '@/ui/ProfileLayoutShell';
import { ProfileLayoutShellTemplate } from '@/templates/ProfileLayoutShellTemplate';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface ProfileLayoutProps {
@@ -18,5 +18,5 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
redirect(result.to);
}
return <ProfileLayoutShell>{children}</ProfileLayoutShell>;
return <ProfileLayoutShellTemplate viewData={{}}>{children}</ProfileLayoutShellTemplate>;
}

View File

@@ -1,13 +1,4 @@
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { routes } from '@/lib/routing/RouteConfig';
import { LiveryCard } from '@/ui/LiveryCard';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { ProfileLiveriesTemplate } from '@/templates/ProfileLiveriesTemplate';
export default async function ProfileLiveriesPage() {
const mockLiveries = [
@@ -29,29 +20,5 @@ export default async function ProfileLiveriesPage() {
}
];
return (
<Container size="lg" py={8}>
<Stack direction="row" align="center" justify="between" mb={8}>
<Box>
<Heading level={1}>My Liveries</Heading>
<Text color="text-gray-400" mt={1} block>Manage your custom car liveries</Text>
</Box>
<Link href={routes.protected.profileLiveryUpload}>
<Button variant="primary">Upload livery</Button>
</Link>
</Stack>
<Grid cols={3} gap={6}>
{mockLiveries.map((livery) => (
<LiveryCard key={livery.id} livery={livery} />
))}
</Grid>
<Box mt={12}>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</Box>
</Container>
);
return <ProfileLiveriesTemplate viewData={{ liveries: mockLiveries }} />;
}

View File

@@ -0,0 +1,107 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { routes } from '@/lib/routing/RouteConfig';
import { UploadDropzone } from '@/components/media/UploadDropzone';
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
export function ProfileLiveryUploadPageClient() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFilesSelected = (files: File[]) => {
if (files.length > 0) {
const file = files[0];
setSelectedFile(file);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setSelectedFile(null);
setPreviewUrl(null);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
// Mock upload delay
await new Promise(resolve => setTimeout(resolve, 2000));
setIsUploading(false);
alert('Livery uploaded successfully! (Mock)');
};
return (
<Container size="md">
<Box mb={6}>
<Heading level={1}>Upload livery</Heading>
<Text color="text-gray-500">
Upload your custom car livery. Supported formats: .png, .jpg, .tga
</Text>
</Box>
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
<Box>
<Card>
<UploadDropzone
onFilesSelected={handleFilesSelected}
accept=".png,.jpg,.jpeg,.tga"
maxSize={10 * 1024 * 1024} // 10MB
isLoading={isUploading}
/>
<Box mt={6} display="flex" justifyContent="end" gap={3}>
<Link href={routes.protected.profileLiveries}>
<Button variant="ghost">Cancel</Button>
</Link>
<Button
variant="primary"
disabled={!selectedFile || isUploading}
onClick={handleUpload}
isLoading={isUploading}
>
Upload Livery
</Button>
</Box>
</Card>
</Box>
<Box>
{previewUrl ? (
<Box display="flex" flexDirection="col" gap={6}>
<MediaPreviewCard
src={previewUrl}
title={selectedFile?.name}
subtitle="Preview"
aspectRatio="16/9"
/>
<MediaMetaPanel
items={mapMediaMetadata({
filename: selectedFile?.name,
size: selectedFile?.size,
contentType: selectedFile?.type || 'image/tga',
createdAt: new Date(),
})}
/>
</Box>
) : (
<Card center p={12}>
<Text color="text-gray-500" align="center">
Select a file to see preview and details
</Text>
</Card>
)}
</Box>
</Box>
</Container>
);
}

View File

@@ -1,21 +1,6 @@
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
import React from 'react';
import { ProfileLiveryUploadPageClient } from './ProfileLiveryUploadPageClient';
export default async function ProfileLiveryUploadPage() {
return (
<Container size="md">
<Heading level={1}>Upload livery</Heading>
<Card>
<Text block mb={4}>Livery upload is currently unavailable.</Text>
<Link href={routes.protected.profileLiveries}>
<Button variant="secondary">Back to liveries</Button>
</Link>
</Card>
</Container>
);
return <ProfileLiveryUploadPageClient />;
}

View File

@@ -1,6 +1,5 @@
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
import { notFound } from 'next/navigation';
import { updateProfileAction } from './actions';
import { ProfilePageClient } from './ProfilePageClient';
export default async function ProfilePage() {
@@ -18,7 +17,6 @@ export default async function ProfilePage() {
<ProfilePageClient
viewData={viewData}
mode={mode}
onSaveSettings={updateProfileAction}
/>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProfileSettingsTemplate } from '@/templates/ProfileSettingsTemplate';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import type { Result } from '@/lib/contracts/Result';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import { Box } from '@/ui/Box';
interface ProfileSettingsPageClientProps {
viewData: ProfileViewData;
onSave: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
}
export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsPageClientProps) {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bio, setBio] = useState(viewData.driver.bio || '');
const [country, setCountry] = useState(viewData.driver.countryCode);
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
const result = await onSave({ bio, country });
if (result.isErr()) {
setError(result.getError());
} else {
router.refresh();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
return (
<>
<ProgressLine isLoading={isSaving} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Update Failed"
message={error}
/>
</Box>
)}
<ProfileSettingsTemplate
viewData={viewData}
bio={bio}
country={country}
onBioChange={setBio}
onCountryChange={setCountry}
onSave={handleSave}
/>
</>
);
}

View File

@@ -1,21 +1,22 @@
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
import { notFound } from 'next/navigation';
import { updateProfileAction } from '@/app/actions/profileActions';
import { ProfileSettingsPageClient } from './ProfileSettingsPageClient';
export default async function ProfileSettingsPage() {
const query = new ProfilePageQuery();
const result = await query.execute();
if (result.isErr()) {
notFound();
}
const viewData = result.unwrap();
return (
<Container size="md">
<Heading level={1}>Settings</Heading>
<Card>
<Text block mb={4}>Settings are currently unavailable.</Text>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</Card>
</Container>
<ProfileSettingsPageClient
viewData={viewData}
onSave={updateProfileAction}
/>
);
}

View File

@@ -1,8 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
import { Box } from '@/ui/Box';
interface SponsorshipRequestsClientProps {
viewData: SponsorshipRequestsViewData;
@@ -11,25 +16,54 @@ interface SponsorshipRequestsClientProps {
}
export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: SponsorshipRequestsClientProps) {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAccept = async (requestId: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onAccept(requestId);
if (result.isErr()) {
console.error('Failed to accept request:', result.getError());
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
const handleReject = async (requestId: string, reason?: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onReject(requestId, reason);
if (result.isErr()) {
console.error('Failed to reject request:', result.getError());
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
return (
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={handleAccept}
onReject={handleReject}
/>
<>
<ProgressLine isLoading={!!isProcessing} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Action Failed"
message={error}
/>
</Box>
)}
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={handleAccept}
onReject={handleReject}
processingId={isProcessing}
/>
</>
);
}

View File

@@ -1,57 +0,0 @@
'use server';
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { revalidatePath } from 'next/cache';
import { Result } from '@/lib/contracts/Result';
import { routes } from '@/lib/routing/RouteConfig';
export async function acceptSponsorshipRequest(
requestId: string,
): Promise<Result<void, string>> {
// Get session for actorDriverId
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
const actorDriverId = session?.user?.primaryDriverId;
if (!actorDriverId) {
return Result.err('Not authenticated');
}
const mutation = new AcceptSponsorshipRequestMutation();
const result = await mutation.execute({ requestId, actorDriverId });
if (result.isErr()) {
console.error('Failed to accept sponsorship request:', result.getError());
return Result.err(result.getError());
}
revalidatePath(routes.protected.profileSponsorshipRequests);
return Result.ok(undefined);
}
export async function rejectSponsorshipRequest(
requestId: string,
reason?: string,
): Promise<Result<void, string>> {
// Get session for actorDriverId
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
const actorDriverId = session?.user?.primaryDriverId;
if (!actorDriverId) {
return Result.err('Not authenticated');
}
const mutation = new RejectSponsorshipRequestMutation();
const result = await mutation.execute({ requestId, actorDriverId, reason: reason || null });
if (result.isErr()) {
console.error('Failed to reject sponsorship request:', result.getError());
return Result.err(result.getError());
}
revalidatePath(routes.protected.profileSponsorshipRequests);
return Result.ok(undefined);
}

View File

@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from '@/app/actions/sponsorshipActions';
export default async function SponsorshipRequestsPage() {
// Execute PageQuery