website refactor
This commit is contained in:
71
apps/website/components/profile/ConnectedAccountsPanel.tsx
Normal file
71
apps/website/components/profile/ConnectedAccountsPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Globe, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
interface ConnectedAccountsPanelProps {
|
||||
iracingId?: string | number;
|
||||
onConnectIRacing?: () => void;
|
||||
}
|
||||
|
||||
export function ConnectedAccountsPanel({ iracingId, onConnectIRacing }: ConnectedAccountsPanelProps) {
|
||||
return (
|
||||
<section aria-labelledby="connected-accounts-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="connected-accounts-heading" fontSize="1.125rem">Connected Accounts</Heading>
|
||||
|
||||
<Stack gap={4} className="divide-y divide-border-gray/30">
|
||||
<Stack direction="row" justify="between" align="center" pt={0}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box backgroundColor="#1e293b" p={2} rounded="md">
|
||||
<Globe size={20} color="#4ED4E0" />
|
||||
</Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="bold" size="sm">iRacing</Text>
|
||||
<Text size="xs" color="#9ca3af">
|
||||
{iracingId ? `Connected ID: ${iracingId}` : 'Not connected'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{!iracingId && (
|
||||
<Button size="sm" variant="secondary" onClick={onConnectIRacing}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{iracingId && (
|
||||
<Box backgroundColor="rgba(16, 185, 129, 0.1)" px={2} py={1} rounded="full">
|
||||
<Text size="xs" color="#10b981" weight="bold">Verified</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justify="between" align="center" pt={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box backgroundColor="#1e293b" p={2} rounded="md">
|
||||
<LinkIcon size={20} color="#198CFF" />
|
||||
</Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="bold" size="sm">Discord</Text>
|
||||
<Text size="xs" color="#9ca3af">Connect for notifications</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Button size="sm" variant="secondary">
|
||||
Connect
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
28
apps/website/components/profile/LiveryGallery.tsx
Normal file
28
apps/website/components/profile/LiveryGallery.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { LiveryCard } from '@/ui/LiveryCard';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
import { ProfileLiveryViewData } from '@/lib/view-data/ProfileLiveriesViewData';
|
||||
|
||||
interface LiveryGalleryProps {
|
||||
liveries: ProfileLiveryViewData[];
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LiveryGallery({ liveries, action }: LiveryGalleryProps) {
|
||||
return (
|
||||
<ProfileSection
|
||||
title="My Liveries"
|
||||
description="Manage your custom car liveries and designs."
|
||||
action={action}
|
||||
>
|
||||
<Grid cols={3} gap={6}>
|
||||
{liveries.map((livery) => (
|
||||
<LiveryCard key={livery.id} livery={livery} />
|
||||
))}
|
||||
</Grid>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
66
apps/website/components/profile/MembershipPanel.tsx
Normal file
66
apps/website/components/profile/MembershipPanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { LeagueListItem } from '@/ui/LeagueListItem';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
|
||||
interface League {
|
||||
leagueId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl?: string;
|
||||
memberCount: number;
|
||||
roleLabel: string;
|
||||
}
|
||||
|
||||
interface MembershipPanelProps {
|
||||
ownedLeagues: League[];
|
||||
memberLeagues: League[];
|
||||
}
|
||||
|
||||
export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) {
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<ProfileSection
|
||||
title="Leagues You Own"
|
||||
description="Manage the leagues you have created and lead."
|
||||
>
|
||||
{ownedLeagues.length === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
You don't own any leagues yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{ownedLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} isAdmin />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection
|
||||
title="Leagues You're In"
|
||||
description="Leagues where you are a participating member."
|
||||
>
|
||||
{memberLeagues.length === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
You're not a member of any other leagues yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{memberLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ProfileSection>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
97
apps/website/components/profile/PreferencesPanel.tsx
Normal file
97
apps/website/components/profile/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
preferences: {
|
||||
favoriteCarClass: string;
|
||||
favoriteSeries: string;
|
||||
competitiveLevel: string;
|
||||
showProfile: boolean;
|
||||
showHistory: boolean;
|
||||
};
|
||||
isEditing?: boolean;
|
||||
onUpdate?: (updates: Partial<PreferencesPanelProps['preferences']>) => void;
|
||||
}
|
||||
|
||||
export function PreferencesPanel({ preferences, isEditing, onUpdate }: PreferencesPanelProps) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<section aria-labelledby="preferences-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={preferences.favoriteCarClass}
|
||||
onChange={(e) => onUpdate?.({ favoriteCarClass: e.target.value })}
|
||||
options={[
|
||||
{ value: 'GT3', label: 'GT3' },
|
||||
{ value: 'GT4', label: 'GT4' },
|
||||
{ value: 'Formula', label: 'Formula' },
|
||||
{ value: 'LMP2', label: 'LMP2' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Competitive Level"
|
||||
value={preferences.competitiveLevel}
|
||||
onChange={(e) => onUpdate?.({ competitiveLevel: e.target.value })}
|
||||
options={[
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'competitive', label: 'Competitive' },
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap={3} pt={2}>
|
||||
<Toggle
|
||||
label="Public Profile"
|
||||
checked={preferences.showProfile}
|
||||
onChange={(checked) => onUpdate?.({ showProfile: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Race History"
|
||||
checked={preferences.showHistory}
|
||||
onChange={(checked) => onUpdate?.({ showHistory: checked })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="preferences-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading>
|
||||
|
||||
<Stack direction="row" gap={8} wrap>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Car Class</Text>
|
||||
<Text color="#d1d5db">{preferences.favoriteCarClass}</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Level</Text>
|
||||
<Text color="#d1d5db" capitalize>{preferences.competitiveLevel}</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Visibility</Text>
|
||||
<Text color="#d1d5db">{preferences.showProfile ? 'Public' : 'Private'}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
81
apps/website/components/profile/ProfileDetailsPanel.tsx
Normal file
81
apps/website/components/profile/ProfileDetailsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
|
||||
interface ProfileDetailsPanelProps {
|
||||
driver: {
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string | null;
|
||||
};
|
||||
isEditing?: boolean;
|
||||
onUpdate?: (updates: { bio?: string; country?: string }) => void;
|
||||
}
|
||||
|
||||
export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDetailsPanelProps) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<section aria-labelledby="profile-details-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading>
|
||||
<Stack gap={4}>
|
||||
<Input
|
||||
label="Nationality (ISO Code)"
|
||||
value={driver.country}
|
||||
onChange={(e) => onUpdate?.({ country: e.target.value })}
|
||||
placeholder="e.g. US, GB, DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
<TextArea
|
||||
label="Bio"
|
||||
value={driver.bio || ''}
|
||||
onChange={(e) => onUpdate?.({ bio: e.target.value })}
|
||||
placeholder="Tell the community about your racing career..."
|
||||
rows={4}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="profile-details-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Nationality</Text>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="xl">
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
<Text color="#d1d5db">{driver.country}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Bio</Text>
|
||||
<Text color="#d1d5db" lineHeight="relaxed">
|
||||
{driver.bio || 'No bio provided.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
140
apps/website/components/profile/ProfileHeader.tsx
Normal file
140
apps/website/components/profile/ProfileHeader.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
country: string;
|
||||
iracingId: number;
|
||||
joinedAt: string | Date;
|
||||
};
|
||||
stats: {
|
||||
rating: number;
|
||||
} | null;
|
||||
globalRank: number;
|
||||
onAddFriend?: () => void;
|
||||
friendRequestSent?: boolean;
|
||||
isOwnProfile?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileHeader({
|
||||
driver,
|
||||
stats,
|
||||
globalRank,
|
||||
onAddFriend,
|
||||
friendRequestSent,
|
||||
isOwnProfile,
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<header>
|
||||
<Surface variant="muted" rounded="xl" border padding={6} backgroundColor="#141619" borderColor="#23272B">
|
||||
<Stack direction="row" align="center" gap={8} wrap>
|
||||
{/* Avatar with telemetry-style border */}
|
||||
<Box position="relative">
|
||||
<Box
|
||||
width="6rem"
|
||||
height="6rem"
|
||||
rounded="md"
|
||||
border
|
||||
borderColor="#23272B"
|
||||
p={0.5}
|
||||
backgroundColor="#0C0D0F"
|
||||
>
|
||||
<Box width="100%" height="100%" rounded="sm" overflow="hidden">
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={96}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={1}>
|
||||
<Heading level={1} fontSize="1.5rem">
|
||||
{driver.name}
|
||||
</Heading>
|
||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={4} color="#9ca3af">
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Globe size={14} />
|
||||
<Text size="xs" font="mono">ID: {driver.iracingId}</Text>
|
||||
</Stack>
|
||||
<Box width="1px" height="12px" backgroundColor="#23272B" />
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Calendar size={14} />
|
||||
<Text size="xs">
|
||||
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
{stats && (
|
||||
<>
|
||||
<Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">RATING</Text>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Star size={14} color="#198CFF" />
|
||||
<Text font="mono" size="lg" weight="bold" color="#198CFF">{stats.rating}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box width="1px" height="32px" backgroundColor="#23272B" />
|
||||
<Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">GLOBAL RANK</Text>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Trophy size={14} color="#FFBE4D" />
|
||||
<Text font="mono" size="lg" weight="bold" color="#FFBE4D">#{globalRank}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Actions */}
|
||||
{!isOwnProfile && onAddFriend && (
|
||||
<Box ml="auto">
|
||||
<Button
|
||||
variant={friendRequestSent ? 'secondary' : 'primary'}
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
size="sm"
|
||||
icon={<UserPlus size={16} />}
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
64
apps/website/components/profile/ProfileNavTabs.tsx
Normal file
64
apps/website/components/profile/ProfileNavTabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export type ProfileTab = 'overview' | 'history' | 'stats' | 'leagues' | 'liveries' | 'settings';
|
||||
|
||||
interface ProfileNavTabsProps {
|
||||
activeTab: ProfileTab;
|
||||
onTabChange: (tab: ProfileTab) => void;
|
||||
tabs?: { id: ProfileTab; label: string }[];
|
||||
}
|
||||
|
||||
const DEFAULT_TABS: { id: ProfileTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'history', label: 'History' },
|
||||
{ id: 'stats', label: 'Stats' },
|
||||
{ id: 'leagues', label: 'Leagues' },
|
||||
{ id: 'liveries', label: 'Liveries' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
export function ProfileNavTabs({ activeTab, onTabChange, tabs = DEFAULT_TABS }: ProfileNavTabsProps) {
|
||||
return (
|
||||
<nav aria-label="Profile navigation">
|
||||
<Box borderBottom borderColor="#23272B">
|
||||
<Stack direction="row" gap={8}>
|
||||
{tabs.map((tab) => (
|
||||
<Box
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
pb={4}
|
||||
cursor="pointer"
|
||||
borderBottom
|
||||
borderColor={activeTab === tab.id ? '#198CFF' : 'transparent'}
|
||||
color={activeTab === tab.id ? '#198CFF' : '#9ca3af'}
|
||||
transition
|
||||
fontSize="0.875rem"
|
||||
fontWeight={activeTab === tab.id ? '600' : '400'}
|
||||
mb="-1px"
|
||||
position="relative"
|
||||
className="group"
|
||||
>
|
||||
<Text capitalize>{tab.label}</Text>
|
||||
{activeTab === tab.id && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
backgroundColor="#198CFF"
|
||||
boxShadow="0 0 8px rgba(25, 140, 255, 0.5)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
33
apps/website/components/profile/ProfileSection.tsx
Normal file
33
apps/website/components/profile/ProfileSection.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ProfileSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileSection({ title, description, action, children }: ProfileSectionProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" size="sm" mt={1} block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{action && <Box>{action}</Box>}
|
||||
</Stack>
|
||||
<Box>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
168
apps/website/components/profile/ProfileSettingsPanel.tsx
Normal file
168
apps/website/components/profile/ProfileSettingsPanel.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { Checkbox } from '@/ui/Checkbox';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
|
||||
interface ProfileSettingsPanelProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string | null;
|
||||
};
|
||||
onSave?: (updates: { bio?: string; country?: string }) => void;
|
||||
}
|
||||
|
||||
export function ProfileSettingsPanel({ driver, onSave }: ProfileSettingsPanelProps) {
|
||||
const [bio, setBio] = useState(driver.bio || '');
|
||||
const [nationality, setNationality] = useState(driver.country);
|
||||
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
|
||||
const [favoriteSeries, setFavoriteSeries] = useState('Endurance');
|
||||
const [competitiveLevel, setCompetitiveLevel] = useState('competitive');
|
||||
const [preferredRegions, setPreferredRegions] = useState<string[]>(['EU']);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
bio,
|
||||
country: nationality
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<ProfileSection
|
||||
title="Profile Information"
|
||||
description="Update your personal details and driver bio."
|
||||
>
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<TextArea
|
||||
label="Bio"
|
||||
value={bio}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Tell us about yourself..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nationality"
|
||||
type="text"
|
||||
value={nationality}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNationality(e.target.value)}
|
||||
placeholder="e.g., US, GB, DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection
|
||||
title="Racing Preferences"
|
||||
description="Help us tailor your experience by sharing your racing style."
|
||||
>
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={favoriteCarClass}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteCarClass(e.target.value)}
|
||||
options={[
|
||||
{ value: 'GT3', label: 'GT3' },
|
||||
{ value: 'GT4', label: 'GT4' },
|
||||
{ value: 'Formula', label: 'Formula' },
|
||||
{ value: 'LMP2', label: 'LMP2' },
|
||||
{ value: 'Touring', label: 'Touring Cars' },
|
||||
{ value: 'NASCAR', label: 'NASCAR' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Favorite Series Type"
|
||||
value={favoriteSeries}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteSeries(e.target.value)}
|
||||
options={[
|
||||
{ value: 'Sprint', label: 'Sprint' },
|
||||
{ value: 'Endurance', label: 'Endurance' },
|
||||
{ value: 'Mixed', label: 'Mixed' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Competitive Level"
|
||||
value={competitiveLevel}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCompetitiveLevel(e.target.value)}
|
||||
options={[
|
||||
{ value: 'casual', label: 'Casual - Just for fun' },
|
||||
{ value: 'competitive', label: 'Competitive - Aiming to win' },
|
||||
{ value: 'professional', label: 'Professional - Esports focused' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Heading level={4}>Preferred Regions</Heading>
|
||||
<Stack direction="row" gap={4}>
|
||||
{['NA', 'EU', 'ASIA', 'OCE'].map(region => (
|
||||
<Checkbox
|
||||
key={region}
|
||||
label={region}
|
||||
checked={preferredRegions.includes(region)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setPreferredRegions([...preferredRegions, region]);
|
||||
} else {
|
||||
setPreferredRegions(preferredRegions.filter(r => r !== region));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection
|
||||
title="Privacy Settings"
|
||||
description="Control who can see your profile and activity."
|
||||
>
|
||||
<Card>
|
||||
<Stack gap={2}>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Show profile to other drivers"
|
||||
/>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Show race history"
|
||||
/>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Allow friend requests"
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</ProfileSection>
|
||||
|
||||
<Stack direction="row" gap={3} justify="end">
|
||||
<Button variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/profile/SessionHistoryTable.tsx
Normal file
87
apps/website/components/profile/SessionHistoryTable.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Trophy, Calendar } from 'lucide-react';
|
||||
|
||||
interface SessionResult {
|
||||
id: string;
|
||||
date: string;
|
||||
event: string;
|
||||
car: string;
|
||||
position: number;
|
||||
fieldSize: number;
|
||||
ratingChange: number;
|
||||
}
|
||||
|
||||
interface SessionHistoryTableProps {
|
||||
results: SessionResult[];
|
||||
}
|
||||
|
||||
export function SessionHistoryTable({ results }: SessionHistoryTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Stack align="center" justify="center" py={12} gap={4}>
|
||||
<Trophy size={48} color="#23272B" />
|
||||
<Text color="#6b7280">No race history found.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Event</TableHeader>
|
||||
<TableHeader>Car</TableHeader>
|
||||
<TableHeader>Pos</TableHeader>
|
||||
<TableHeader className="text-right">Rating</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{results.map((result) => (
|
||||
<TableRow key={result.id}>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Calendar size={12} className="text-gray-500" />
|
||||
<Text size="xs" font="mono">
|
||||
{new Date(result.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="bold" size="sm">{result.event}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="#9ca3af">{result.car}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Badge
|
||||
variant={result.position === 1 ? 'success' : result.position <= 3 ? 'warning' : 'default'}
|
||||
>
|
||||
P{result.position}
|
||||
</Badge>
|
||||
<Text size="xs" color="#6b7280">/ {result.fieldSize}</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text
|
||||
size="xs"
|
||||
font="mono"
|
||||
weight="bold"
|
||||
color={result.ratingChange >= 0 ? '#10b981' : '#ef4444'}
|
||||
>
|
||||
{result.ratingChange >= 0 ? '+' : ''}{result.ratingChange}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
101
apps/website/components/profile/SponsorshipRequestsPanel.tsx
Normal file
101
apps/website/components/profile/SponsorshipRequestsPanel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
|
||||
interface Request {
|
||||
id: string;
|
||||
sponsorName: string;
|
||||
message?: string | null;
|
||||
createdAtIso: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
entityType: string;
|
||||
requests: Request[];
|
||||
}
|
||||
|
||||
interface SponsorshipRequestsPanelProps {
|
||||
sections: Section[];
|
||||
onAccept: (requestId: string) => Promise<void>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<void>;
|
||||
processingId?: string | null;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestsPanel({
|
||||
sections,
|
||||
onAccept,
|
||||
onReject,
|
||||
processingId,
|
||||
}: SponsorshipRequestsPanelProps) {
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
{sections.map((section) => (
|
||||
<ProfileSection
|
||||
key={`${section.entityType}-${section.entityId}`}
|
||||
title={section.entityName}
|
||||
description={`Manage pending sponsorship requests for ${section.entityName}.`}
|
||||
>
|
||||
<Card>
|
||||
{section.requests.length === 0 ? (
|
||||
<Text size="sm" color="text-gray-400">No pending requests.</Text>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{section.requests.map((request) => (
|
||||
<Surface
|
||||
key={request.id}
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
backgroundColor="#0C0D0F"
|
||||
borderColor="#23272B"
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" block>{request.sponsorName}</Text>
|
||||
{request.message && (
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
|
||||
)}
|
||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||
{DateDisplay.formatShort(request.createdAtIso)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack direction="row" gap={2}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onAccept(request.id)}
|
||||
size="sm"
|
||||
disabled={!!processingId}
|
||||
>
|
||||
{processingId === request.id ? 'Accepting...' : 'Accept'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onReject(request.id)}
|
||||
size="sm"
|
||||
disabled={!!processingId}
|
||||
>
|
||||
{processingId === request.id ? 'Rejecting...' : 'Reject'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</ProfileSection>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user