Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import client from './apolloClient';
import { usePageTracking } from './hooks/usePageTracking';
import { CompetitionLayout } from './layouts/CompetitionLayout';
import { RootLayout } from './layouts/RootLayout';
import { FEATURE_FLAGS } from './lib/featureFlags';
import About from './pages/About';
import CompetitionAdmin from './pages/Competition/Admin';
import CompetitionEvents from './pages/Competition/ByGroup/Events';
Expand Down Expand Up @@ -36,6 +37,7 @@ import LiveActivitiesAbout from './pages/LiveActivities/About';
import Settings from './pages/Settings';
import Support from './pages/Support';
import Test from './pages/Test';
import UserPage from './pages/User';
import UserLogin from './pages/UserLogin';
import { AppProvider } from './providers/AppProvider';
import { AuthProvider, useAuth } from './providers/AuthProvider';
Expand Down Expand Up @@ -86,6 +88,24 @@ const PsychSheet = () => {
return null;
};

const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
const { wcif } = useWCIF();
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());

if (!wcif) {
return null;
}

if (!person) {
return <Navigate to={`/competitions/${competitionId}`} replace />;
}

return (
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
);
};

const CompetitionRedirect = ({ to }: { to: string }) => {
const { competitionId } = useParams() as { competitionId: string };

Expand All @@ -103,6 +123,14 @@ const Navigation = () => {
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
<Route index element={<CompetitionHome />} />

<Route
path="persons/wca/:wcaId/results"
element={<CompetitionPersonByWcaIdRedirect to="results" />}
/>
<Route
path="persons/wca/:wcaId/records"
element={<CompetitionPersonByWcaIdRedirect to="records" />}
/>
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
Expand Down Expand Up @@ -142,6 +170,16 @@ const Navigation = () => {
<Route path="*" element={<p>Path not resolved</p>} />
</Route>
<Route path="/users/:userId" element={<UserLogin />} />
{FEATURE_FLAGS.personalUserPage && (
<>
<Route path="/me" element={<Navigate to="/me/competitions" replace />} />
<Route
path="/me/results/:resultsMode"
element={<Navigate to="/me/results" replace />}
/>
<Route path="/me/:tab" element={<UserPage />} />
</>
)}
<Route path="about" element={<About />} />
<Route path="live-activities" element={<LiveActivitiesAbout />} />
<Route path="settings" element={<Settings />} />
Expand Down
11 changes: 8 additions & 3 deletions src/containers/MyCompetitions/MyCompetitions.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { fetchUserWithCompetitions, UserCompsResponse } from '@/lib/api';
import { FIVE_MINUTES } from '@/lib/constants';
import { getLocalStorage, setLocalStorage } from '@/lib/localStorage';

export const useMyCompetitionsQuery = (userId?: number) => {
export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boolean } = {}) => {
const { data, ...props } = useQuery<UserCompsResponse, string>({
queryKey: ['userCompetitions'],
queryKey: ['userCompetitions', userId],
queryFn: async () => {
const res = await fetchUserWithCompetitions(userId!.toString());

Expand All @@ -16,10 +16,15 @@ export const useMyCompetitionsQuery = (userId?: number) => {

return res;
},
staleTime: FIVE_MINUTES,
gcTime: FIVE_MINUTES,
enabled: !!userId,
enabled: Boolean(userId && (options.enabled ?? true)),
initialData: () => {
const user = JSON.parse(getLocalStorage('user') || 'null') as User;
if (user?.id !== userId) {
return undefined;
}

const upcoming_competitions = JSON.parse(
getLocalStorage('my.upcoming_competitions') || '[]',
) as ApiCompetition[];
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/queries/useWcif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export const useWcif = (competitionId?: string) =>
queryClient
.getQueryData<InfiniteData<CondensedApiCompetiton[]>>(['upcomingCompetitions'])
?.pages?.flat() || [];
const myUpcomingComps = queryClient?.getQueryData<UserCompsResponse>(['userCompetitions']);
const myUpcomingComps = queryClient
?.getQueriesData<UserCompsResponse>({ queryKey: ['userCompetitions'] })
.map(([, data]) => data)
.find(Boolean);

const allComps = [
...upcomingComps,
Expand Down
17 changes: 13 additions & 4 deletions src/layouts/RootLayout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { Popover } from 'react-tiny-popover';
import { useCompetition } from '@/hooks/queries/useCompetition';
import { UserCompsResponse } from '@/lib/api';
import { FEATURE_FLAGS } from '@/lib/featureFlags';
import { useAuth } from '@/providers/AuthProvider';
import { useWCIF } from '@/providers/WCIFProvider';

Expand All @@ -30,13 +32,15 @@ export default function Header() {
const upcomingCompetitions = queryClient.getQueryData<{ pages: CondensedApiCompetiton[][] }>([
'upcomingCompetitions',
]);
const myCompetitions = queryClient.getQueryData<{ pages: CondensedApiCompetiton[][] }>([
'userCompetitions',
]);
const myCompetitions = queryClient
.getQueriesData<UserCompsResponse>({ queryKey: ['userCompetitions'] })
.map(([, data]) => data)
.find(Boolean);

const allCompetitions = [
...(upcomingCompetitions?.pages?.flat() || []),
...(myCompetitions?.pages?.flat() || []),
...(myCompetitions?.upcoming_competitions || []),
...(myCompetitions?.ongoing_competitions || []),
];

const competition = allCompetitions?.find((c) => c.id === competitionId);
Expand Down Expand Up @@ -73,6 +77,11 @@ export default function Header() {
<div
className="z-50 mt-2 border-2 shadow-xl bg-panel border-tertiary-weak"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
{FEATURE_FLAGS.personalUserPage && (
<Link to="/me" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
My profile
</Link>
)}
<Link to="/settings" className="block w-32 px-3 py-2 link-inline hover-bg-tertiary">
Settings
</Link>
Expand Down
21 changes: 21 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,30 @@ export interface WcaCompetitionResult {
attempts: number[];
}

export type WcaPersonCompetition = Pick<
ApiCompetition,
| 'id'
| 'name'
| 'short_name'
| 'city'
| 'country_iso2'
| 'start_date'
| 'end_date'
| 'announced_at'
| 'cancelled_at'
| 'latitude_degrees'
| 'longitude_degrees'
| 'venue_address'
| 'venue_details'
| 'website'
>;

export const fetchCompetitionResults = async (competitionId: string) =>
wcaApiFetch<WcaCompetitionResult[]>(`/competitions/${competitionId}/results`);

export const fetchPersonCompetitions = async (wcaId: string) =>
wcaApiFetch<WcaPersonCompetition[]>(`/persons/${wcaId}/competitions`);

export const fetchCompetition = async (competitionId: string) =>
await wcaApiFetch<ApiCompetition>(`/competitions/${competitionId}`);

Expand Down
8 changes: 8 additions & 0 deletions src/lib/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const enabledValues = new Set(['1', 'true', 'yes', 'on']);

const isEnabled = (value: string | undefined) =>
value ? enabledValues.has(value.toLowerCase()) : false;

export const FEATURE_FLAGS = {
personalUserPage: isEnabled(import.meta.env.VITE_FEATURE_PERSONAL_USER_PAGE),
};
134 changes: 134 additions & 0 deletions src/pages/User/components/CompetitionsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Link } from 'react-router-dom';
import { WcaPersonCompetition } from '@/lib/api';
import { getPersonResultsPath } from '../userProfileData';

interface CompetitionsTabProps {
competitions: ApiCompetition[];
assignmentStatus: Record<string, boolean> | undefined;
isCheckingAssignments: boolean;
pastCompetitions: WcaPersonCompetition[] | undefined;
isLoadingPastCompetitions: boolean;
wcaId?: string;
}

const formatCompetitionDates = (competition: Pick<ApiCompetition, 'start_date' | 'end_date'>) => {
const start = new Date(`${competition.start_date}T00:00:00`);
const end = new Date(`${competition.end_date}T00:00:00`);

if (competition.start_date === competition.end_date) {
return start.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

return `${start.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})} - ${end.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}`;
};

export function CompetitionsTab({
competitions,
assignmentStatus,
isCheckingAssignments,
pastCompetitions,
isLoadingPastCompetitions,
wcaId,
}: CompetitionsTabProps) {
const visibleCompetitionIds = new Set(competitions.map((competition) => competition.id));
const sortedPastCompetitions = [...(pastCompetitions || [])]
.filter((competition) => !visibleCompetitionIds.has(competition.id))
.sort((a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime());

return (
<div className="space-y-4">
<section className="space-y-2">
<h2 className="type-label text-default">Upcoming competitions</h2>
{competitions.length === 0 ? (
<p className="type-body-sm text-muted">No upcoming competitions.</p>
) : (
competitions.map((competition) => {
const hasAssignments = assignmentStatus?.[competition.id];
const statusText =
hasAssignments == null && isCheckingAssignments
? 'Checking assignments'
: hasAssignments
? 'Assignments generated'
: 'No assignments yet';

return (
<Link
key={competition.id}
to={`/competitions/${competition.id}`}
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="type-label text-default">{competition.name}</div>
<div className="type-body-sm text-subtle">
{competition.city}, {competition.country_iso2} -{' '}
{formatCompetitionDates(competition)}
</div>
</div>
<span
className={
hasAssignments
? 'shrink-0 text-right type-body-sm text-green-600'
: 'shrink-0 text-right type-body-sm text-muted'
}>
{statusText}
</span>
</div>
</Link>
);
})
)}
</section>

<section className="space-y-2">
<h2 className="type-label text-default">Past competitions</h2>
{isLoadingPastCompetitions ? (
<p className="type-body-sm text-muted">Loading past competitions...</p>
) : sortedPastCompetitions.length === 0 ? (
<p className="type-body-sm text-muted">No past competitions.</p>
) : (
sortedPastCompetitions.map((competition) => {
const to = getPersonResultsPath(competition.id, wcaId);
const content = (
<div className="min-w-0 space-y-1">
<div className="type-label text-default">{competition.name}</div>
<div className="type-body-sm text-subtle">
{formatCompetitionDates(competition)}
</div>
</div>
);

if (!to) {
return (
<div
key={competition.id}
className="rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm">
{content}
</div>
);
}

return (
<Link
key={competition.id}
to={to}
className="block rounded-md border border-tertiary-weak bg-panel px-3 py-2 shadow-sm hover-bg-tertiary">
{content}
</Link>
);
})
)}
</section>
</div>
);
}
38 changes: 38 additions & 0 deletions src/pages/User/components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { hasFlag } from 'country-flag-icons';
import getUnicodeFlagIcon from 'country-flag-icons/unicode';

const fallbackAvatarUrl =
'https://assets.worldcubeassociation.org/assets/326cd49/assets/missing_avatar_thumb-d77f478a307a91a9d4a083ad197012a391d5410f6dd26cb0b0e3118a5de71438.png';

interface ProfileHeaderProps {
countryIso2?: string;
user: User;
}

export function ProfileHeader({ countryIso2, user }: ProfileHeaderProps) {
const avatarUrl = user.avatar?.thumb_url || user.avatar?.url || fallbackAvatarUrl;

return (
<div className="flex px-1 space-x-1">
<a
href={`https://worldcubeassociation.org/persons/${user.wca_id}`}
target="_blank"
rel="noreferrer">
<img src={avatarUrl} alt={user.name} className="object-contain w-24 h-24" />
</a>
<div className="flex flex-col w-full">
<div className="flex items-center flex-shrink w-full space-x-1">
<h3 className="type-heading sm:type-title">{user.name}</h3>
</div>
<div className="flex space-x-1 align-center">
{countryIso2 && hasFlag(countryIso2) && (
<div className="flex flex-shrink type-body sm:type-heading">
{getUnicodeFlagIcon(countryIso2)}
</div>
)}
{user.wca_id && <span className="my-1 type-body-sm">{user.wca_id}</span>}
</div>
</div>
</div>
);
}
Loading
Loading