Skip to content

Commit 3f6a6e8

Browse files
committed
Add personal user page with competitions, results, and records tabs
1 parent 596361e commit 3f6a6e8

12 files changed

Lines changed: 1359 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import LiveActivitiesAbout from './pages/LiveActivities/About';
3636
import Settings from './pages/Settings';
3737
import Support from './pages/Support';
3838
import Test from './pages/Test';
39+
import UserPage from './pages/User';
3940
import UserLogin from './pages/UserLogin';
4041
import { AppProvider } from './providers/AppProvider';
4142
import { AuthProvider, useAuth } from './providers/AuthProvider';
@@ -86,6 +87,24 @@ const PsychSheet = () => {
8687
return null;
8788
};
8889

90+
const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
91+
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
92+
const { wcif } = useWCIF();
93+
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());
94+
95+
if (!wcif) {
96+
return null;
97+
}
98+
99+
if (!person) {
100+
return <Navigate to={`/competitions/${competitionId}`} replace />;
101+
}
102+
103+
return (
104+
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
105+
);
106+
};
107+
89108
const CompetitionRedirect = ({ to }: { to: string }) => {
90109
const { competitionId } = useParams() as { competitionId: string };
91110

@@ -103,6 +122,14 @@ const Navigation = () => {
103122
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
104123
<Route index element={<CompetitionHome />} />
105124

125+
<Route
126+
path="persons/wca/:wcaId/results"
127+
element={<CompetitionPersonByWcaIdRedirect to="results" />}
128+
/>
129+
<Route
130+
path="persons/wca/:wcaId/records"
131+
element={<CompetitionPersonByWcaIdRedirect to="records" />}
132+
/>
106133
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
107134
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
108135
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
@@ -142,6 +169,9 @@ const Navigation = () => {
142169
<Route path="*" element={<p>Path not resolved</p>} />
143170
</Route>
144171
<Route path="/users/:userId" element={<UserLogin />} />
172+
<Route path="/me" element={<Navigate to="/me/competitions" replace />} />
173+
<Route path="/me/:tab/:resultsMode" element={<UserPage />} />
174+
<Route path="/me/:tab" element={<UserPage />} />
145175
<Route path="about" element={<About />} />
146176
<Route path="live-activities" element={<LiveActivitiesAbout />} />
147177
<Route path="settings" element={<Settings />} />

src/layouts/RootLayout/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ export default function Header() {
7373
<div
7474
className="z-50 mt-2 border-2 shadow-xl bg-panel border-tertiary-weak"
7575
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
76+
<Link to="/me" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
77+
My profile
78+
</Link>
7679
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
7780
Settings
7881
</Link>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Link } from 'react-router-dom';
2+
import { WcaCompetitionResult } from '@/lib/api';
3+
import {
4+
formatUserResult,
5+
getCompetitionResultSummaries,
6+
getPersonResultsPath,
7+
getUserEventName,
8+
} from '../userProfileData';
9+
10+
interface CompetitionsTabProps {
11+
competitions: ApiCompetition[];
12+
assignmentStatus: Record<string, boolean> | undefined;
13+
isCheckingAssignments: boolean;
14+
results: WcaCompetitionResult[] | undefined;
15+
isLoadingResults: boolean;
16+
wcaId?: string;
17+
}
18+
19+
const formatCompetitionDates = (competition: ApiCompetition) => {
20+
const start = new Date(`${competition.start_date}T00:00:00`);
21+
const end = new Date(`${competition.end_date}T00:00:00`);
22+
23+
if (competition.start_date === competition.end_date) {
24+
return start.toLocaleDateString(undefined, {
25+
month: 'short',
26+
day: 'numeric',
27+
year: 'numeric',
28+
});
29+
}
30+
31+
return `${start.toLocaleDateString(undefined, {
32+
month: 'short',
33+
day: 'numeric',
34+
})} - ${end.toLocaleDateString(undefined, {
35+
month: 'short',
36+
day: 'numeric',
37+
year: 'numeric',
38+
})}`;
39+
};
40+
41+
export function CompetitionsTab({
42+
competitions,
43+
assignmentStatus,
44+
isCheckingAssignments,
45+
results,
46+
isLoadingResults,
47+
wcaId,
48+
}: CompetitionsTabProps) {
49+
const pastCompetitionSummaries = getCompetitionResultSummaries(results || []).sort((a, b) =>
50+
b.competitionId.localeCompare(a.competitionId),
51+
);
52+
53+
return (
54+
<div className="space-y-4">
55+
<section className="space-y-2">
56+
<h2 className="type-label text-default">Upcoming competitions</h2>
57+
{competitions.length === 0 ? (
58+
<p className="type-body-sm text-muted">No upcoming competitions.</p>
59+
) : (
60+
competitions.map((competition) => {
61+
const hasAssignments = assignmentStatus?.[competition.id];
62+
const statusText =
63+
hasAssignments == null && isCheckingAssignments
64+
? 'Checking assignments'
65+
: hasAssignments
66+
? 'Assignments generated'
67+
: 'No assignments yet';
68+
69+
return (
70+
<Link
71+
key={competition.id}
72+
to={`/competitions/${competition.id}`}
73+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
74+
<div className="flex items-start justify-between gap-3">
75+
<div className="min-w-0 space-y-1">
76+
<div className="type-label text-default">{competition.name}</div>
77+
<div className="type-body-sm text-subtle">
78+
{competition.city}, {competition.country_iso2} -{' '}
79+
{formatCompetitionDates(competition)}
80+
</div>
81+
</div>
82+
<span
83+
className={
84+
hasAssignments
85+
? 'shrink-0 text-right type-body-sm text-green-600'
86+
: 'shrink-0 text-right type-body-sm text-muted'
87+
}>
88+
{statusText}
89+
</span>
90+
</div>
91+
</Link>
92+
);
93+
})
94+
)}
95+
</section>
96+
97+
<section className="space-y-2">
98+
<h2 className="type-label text-default">Past competitions</h2>
99+
{isLoadingResults ? (
100+
<p className="type-body-sm text-muted">Loading past competitions...</p>
101+
) : pastCompetitionSummaries.length === 0 ? (
102+
<p className="type-body-sm text-muted">No past competitions.</p>
103+
) : (
104+
pastCompetitionSummaries.map((summary) => {
105+
const to = getPersonResultsPath(summary.competitionId, wcaId);
106+
const content = (
107+
<div className="flex items-start justify-between gap-3">
108+
<div className="min-w-0 space-y-1">
109+
<div className="type-label text-default">{summary.competitionId}</div>
110+
<div className="type-body-sm text-subtle">
111+
{summary.roundCount} rounds across {summary.eventCount} events
112+
</div>
113+
</div>
114+
<div className="shrink-0 text-right type-body-sm text-muted">
115+
<div>{getUserEventName(summary.bestResult.event_id)}</div>
116+
<div>
117+
{formatUserResult(
118+
summary.bestResult.event_id,
119+
'single',
120+
summary.bestResult.best,
121+
)}
122+
</div>
123+
</div>
124+
</div>
125+
);
126+
127+
if (!to) {
128+
return (
129+
<div
130+
key={summary.competitionId}
131+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
132+
{content}
133+
</div>
134+
);
135+
}
136+
137+
return (
138+
<Link
139+
key={summary.competitionId}
140+
to={to}
141+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
142+
{content}
143+
</Link>
144+
);
145+
})
146+
)}
147+
</section>
148+
</div>
149+
);
150+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { hasFlag } from 'country-flag-icons';
2+
import getUnicodeFlagIcon from 'country-flag-icons/unicode';
3+
4+
const fallbackAvatarUrl =
5+
'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png';
6+
7+
interface ProfileHeaderProps {
8+
countryIso2?: string;
9+
user: User;
10+
}
11+
12+
export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) {
13+
const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl;
14+
15+
return (
16+
<div className="flex px-1 space-x-1">
17+
<a
18+
href={`https://worldcubeassociation.org/persons/${user.wca_id}`}
19+
target="_blank"
20+
rel="noreferrer">
21+
<img src={avatarUrl} alt={user.name} className="object-contain w-24 h-24" />
22+
</a>
23+
<div className="flex flex-col w-full">
24+
<div className="flex items-center flex-shrink w-full space-x-1">
25+
<h3 className="type-heading sm:type-title">{user.name}</h3>
26+
</div>
27+
<div className="flex space-x-1 align-center">
28+
{countryIso2 && hasFlag(countryIso2) && (
29+
<div className="flex flex-shrink type-body sm:type-heading">
30+
{getUnicodeFlagIcon(countryIso2)}
31+
</div>
32+
)}
33+
{user.wca_id && <span className="my-1 type-body-sm">{user.wca_id}</span>}
34+
</div>
35+
</div>
36+
</div>
37+
);
38+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import classNames from 'classnames';
2+
import { Link } from 'react-router-dom';
3+
import { UserPageTab } from '../userProfileData';
4+
5+
interface ProfileTabsProps {
6+
activeTab: UserPageTab;
7+
}
8+
9+
const tabs: { id: UserPageTab; label: string }[] = [
10+
{ id: 'competitions', label: 'Competitions' },
11+
{ id: 'results', label: 'Results' },
12+
{ id: 'records', label: 'Records' },
13+
];
14+
15+
const tabPath = (tab: UserPageTab) => (tab === 'results' ? '/me/results/by-event' : `/me/${tab}`);
16+
17+
export function ProfileTabs({ activeTab }: ProfileTabsProps) {
18+
return (
19+
<nav className="overflow-hidden rounded-md border border-tertiary-weak bg-panel shadow-sm">
20+
<div className="grid grid-flow-col auto-cols-fr">
21+
{tabs.map((tab) => {
22+
const isActive = tab.id === activeTab;
23+
24+
return (
25+
<Link
26+
key={tab.id}
27+
to={tabPath(tab.id)}
28+
className={classNames(
29+
'flex min-h-12 items-center justify-center gap-2 border-r border-tertiary-weak px-2 py-2 text-center type-body-sm hover-transition hover:bg-gray-100 last:border-r-0 dark:hover:bg-gray-700 sm:type-body',
30+
{
31+
'bg-active text-primary': isActive,
32+
'text-muted': !isActive,
33+
},
34+
)}>
35+
<span className="truncate">{tab.label}</span>
36+
</Link>
37+
);
38+
})}
39+
</div>
40+
</nav>
41+
);
42+
}

0 commit comments

Comments
 (0)