Skip to content

Commit a14254e

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

13 files changed

Lines changed: 1411 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>

src/lib/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,30 @@ export interface WcaCompetitionResult {
7070
attempts: number[];
7171
}
7272

73+
export type WcaPersonCompetition = Pick<
74+
ApiCompetition,
75+
| 'id'
76+
| 'name'
77+
| 'short_name'
78+
| 'city'
79+
| 'country_iso2'
80+
| 'start_date'
81+
| 'end_date'
82+
| 'announced_at'
83+
| 'cancelled_at'
84+
| 'latitude_degrees'
85+
| 'longitude_degrees'
86+
| 'venue_address'
87+
| 'venue_details'
88+
| 'website'
89+
>;
90+
7391
export const fetchCompetitionResults = async (competitionId: string) =>
7492
wcaApiFetch<WcaCompetitionResult[]>(`/competitions/${competitionId}/results`);
7593

94+
export const fetchPersonCompetitions = async (wcaId: string) =>
95+
wcaApiFetch<WcaPersonCompetition[]>(`/persons/${wcaId}/competitions`);
96+
7697
export const fetchCompetition = async (competitionId: string) =>
7798
await wcaApiFetch<ApiCompetition>(`/competitions/${competitionId}`);
7899

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Link } from 'react-router-dom';
2+
import { WcaPersonCompetition } from '@/lib/api';
3+
import { getPersonResultsPath } from '../userProfileData';
4+
5+
interface CompetitionsTabProps {
6+
competitions: ApiCompetition[];
7+
assignmentStatus: Record<string, boolean> | undefined;
8+
isCheckingAssignments: boolean;
9+
pastCompetitions: WcaPersonCompetition[] | undefined;
10+
isLoadingPastCompetitions: boolean;
11+
wcaId?: string;
12+
}
13+
14+
const formatCompetitionDates = (competition: Pick<ApiCompetition, 'start_date' | 'end_date'>) => {
15+
const start = new Date(`${competition.start_date}T00:00:00`);
16+
const end = new Date(`${competition.end_date}T00:00:00`);
17+
18+
if (competition.start_date === competition.end_date) {
19+
return start.toLocaleDateString(undefined, {
20+
month: 'short',
21+
day: 'numeric',
22+
year: 'numeric',
23+
});
24+
}
25+
26+
return `${start.toLocaleDateString(undefined, {
27+
month: 'short',
28+
day: 'numeric',
29+
})} - ${end.toLocaleDateString(undefined, {
30+
month: 'short',
31+
day: 'numeric',
32+
year: 'numeric',
33+
})}`;
34+
};
35+
36+
export function CompetitionsTab({
37+
competitions,
38+
assignmentStatus,
39+
isCheckingAssignments,
40+
pastCompetitions,
41+
isLoadingPastCompetitions,
42+
wcaId,
43+
}: CompetitionsTabProps) {
44+
const visibleCompetitionIds = new Set(competitions.map((competition) => competition.id));
45+
const sortedPastCompetitions = [...(pastCompetitions || [])]
46+
.filter((competition) => !visibleCompetitionIds.has(competition.id))
47+
.sort((a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime());
48+
49+
return (
50+
<div className="space-y-4">
51+
<section className="space-y-2">
52+
<h2 className="type-label text-default">Upcoming competitions</h2>
53+
{competitions.length === 0 ? (
54+
<p className="type-body-sm text-muted">No upcoming competitions.</p>
55+
) : (
56+
competitions.map((competition) => {
57+
const hasAssignments = assignmentStatus?.[competition.id];
58+
const statusText =
59+
hasAssignments == null && isCheckingAssignments
60+
? 'Checking assignments'
61+
: hasAssignments
62+
? 'Assignments generated'
63+
: 'No assignments yet';
64+
65+
return (
66+
<Link
67+
key={competition.id}
68+
to={`/competitions/${competition.id}`}
69+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
70+
<div className="flex items-start justify-between gap-3">
71+
<div className="min-w-0 space-y-1">
72+
<div className="type-label text-default">{competition.name}</div>
73+
<div className="type-body-sm text-subtle">
74+
{competition.city}, {competition.country_iso2} -{' '}
75+
{formatCompetitionDates(competition)}
76+
</div>
77+
</div>
78+
<span
79+
className={
80+
hasAssignments
81+
? 'shrink-0 text-right type-body-sm text-green-600'
82+
: 'shrink-0 text-right type-body-sm text-muted'
83+
}>
84+
{statusText}
85+
</span>
86+
</div>
87+
</Link>
88+
);
89+
})
90+
)}
91+
</section>
92+
93+
<section className="space-y-2">
94+
<h2 className="type-label text-default">Past competitions</h2>
95+
{isLoadingPastCompetitions ? (
96+
<p className="type-body-sm text-muted">Loading past competitions...</p>
97+
) : sortedPastCompetitions.length === 0 ? (
98+
<p className="type-body-sm text-muted">No past competitions.</p>
99+
) : (
100+
sortedPastCompetitions.map((competition) => {
101+
const to = getPersonResultsPath(competition.id, wcaId);
102+
const content = (
103+
<div className="min-w-0 space-y-1">
104+
<div className="type-label text-default">{competition.name}</div>
105+
<div className="type-body-sm text-subtle">
106+
{formatCompetitionDates(competition)}
107+
</div>
108+
</div>
109+
);
110+
111+
if (!to) {
112+
return (
113+
<div
114+
key={competition.id}
115+
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
116+
{content}
117+
</div>
118+
);
119+
}
120+
121+
return (
122+
<Link
123+
key={competition.id}
124+
to={to}
125+
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
126+
{content}
127+
</Link>
128+
);
129+
})
130+
)}
131+
</section>
132+
</div>
133+
);
134+
}
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)