i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped

This commit is contained in:
2026-08-08 15:29:40 -07:00
parent 22c282d8fa
commit 0a7b5fa00f
178 changed files with 13099 additions and 1285 deletions
@@ -1,4 +1,5 @@
import { useState, useMemo, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import {
Text,
TextInput,
@@ -35,13 +36,14 @@ interface ActivityListItemProps {
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const { t, i18n } = useLingui();
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: "System";
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
return date.toLocaleString(i18n.locale);
};
return (
@@ -78,7 +80,7 @@ const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) =>
{playerName}
</Text>
<Text size="xs" c="dimmed">
{activity.duration}ms
<Trans>{activity.duration}ms</Trans>
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
@@ -102,44 +104,45 @@ interface ActivityDetailsSheetProps {
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
const { t, i18n } = useLingui();
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: "System";
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
return date.toLocaleString(i18n.locale);
};
return (
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Function Name
<Trans>Function Name</Trans>
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Status
<Trans>Status</Trans>
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
Success
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
Failed
<Trans>Failed</Trans>
</Text>
</>
)}
@@ -148,21 +151,21 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Player
<Trans>Player</Trans>
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Duration
<Trans>Duration</Trans>
</Text>
<Text size="sm">{activity.duration}ms</Text>
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Created
<Trans>Created</Trans>
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
@@ -170,7 +173,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
User Agent
<Trans>User Agent</Trans>
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
@@ -181,7 +184,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Error Message
<Trans>Error Message</Trans>
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
@@ -194,7 +197,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
Arguments
<Trans>Arguments</Trans>
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
@@ -228,7 +231,7 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
<PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Activities Found
<Trans>No Activities Found</Trans>
</Title>
</Stack>
)}
@@ -248,6 +251,7 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
};
export const ActivitiesTable = () => {
const { t } = useLingui();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null);
@@ -302,7 +306,7 @@ export const ActivitiesTable = () => {
<Stack gap="xs">
<Stack gap="xs" px="md">
<TextInput
placeholder="serverFn name"
placeholder={t`serverFn name`}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
@@ -314,16 +318,16 @@ export const ActivitiesTable = () => {
<Group>
<Select
placeholder="Status"
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
setSuccessFilter(value);
setPage(1);
}}
data={[
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
{ value: "all", label: t`All` },
{ value: "success", label: t`Success` },
{ value: "failure", label: t`Failure` },
]}
clearable
size="sm"
@@ -334,11 +338,11 @@ export const ActivitiesTable = () => {
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
{result.totalItems} total activities
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
Sort:
<Trans>Sort:</Trans>
</Text>
<UnstyledButton
onClick={() => handleSort("created")}
@@ -349,7 +353,7 @@ export const ActivitiesTable = () => {
fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
>
Date
<Trans>Date</Trans>
</Text>
{getSortIcon("created")}
</UnstyledButton>
@@ -365,7 +369,7 @@ export const ActivitiesTable = () => {
fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
>
Duration
<Trans>Duration</Trans>
</Text>
{getSortIcon("duration")}
</UnstyledButton>
+8 -6
View File
@@ -1,4 +1,5 @@
import { List } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import {
DatabaseIcon,
@@ -13,6 +14,7 @@ import { migrateBadgeProgress } from "@/features/badges/server";
import { useState } from "react";
const AdminPage = () => {
const { t } = useLingui();
const [isMigrating, setIsMigrating] = useState(false);
const handleMigrateBadges = async () => {
@@ -26,35 +28,35 @@ const AdminPage = () => {
return (
<List p="0">
<ListLink
label="Manage Tournaments"
label={t`Manage Tournaments`}
Icon={TrophyIcon}
to="/admin/tournaments"
/>
<ListLink
label="Award Badges"
label={t`Award Badges`}
Icon={CrownIcon}
to="/admin/badges"
/>
<ListButton
label="Migrate Badge Progress"
label={t`Migrate Badge Progress`}
Icon={MedalIcon}
onClick={handleMigrateBadges}
loading={isMigrating}
/>
<ListLink
label="Activities"
label={t`Activities`}
Icon={ListIcon}
to="/admin/activities"
/>
<ListButton
label="Open Pocketbase"
label={t`Open Pocketbase`}
Icon={DatabaseIcon}
onClick={() =>
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
}
/>
<ListLink
label="Bracket Preview"
label={t`Bracket Preview`}
Icon={TreeStructureIcon}
to="/admin/preview"
/>
+11 -9
View File
@@ -1,11 +1,13 @@
import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { awardManualBadge } from "@/features/badges/server";
import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries";
const AwardBadges = () => {
const { t } = useLingui();
const { data: players } = usePlayers();
const { data: allBadges } = useAllBadges();
@@ -30,13 +32,13 @@ const AwardBadges = () => {
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
const playerName = selectedPlayer
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: "Player";
: t`Player`;
toast.success(`Badge awarded to ${playerName}`);
toast.success(t`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
} catch (error) {
toast.error("Failed to award badge");
toast.error(t`Failed to award badge`);
} finally {
setIsAwarding(false);
}
@@ -60,13 +62,13 @@ const AwardBadges = () => {
<Stack gap="lg">
<Box>
<Text size="lg" fw={600} mb="xs">
Award Manual Badge
<Trans>Award Manual Badge</Trans>
</Text>
</Box>
<Select
label="Badge Type"
placeholder="Select a badge"
label={t`Badge Type`}
placeholder={t`Select a badge`}
data={badgeOptions}
value={selectedBadgeId}
onChange={setSelectedBadgeId}
@@ -81,8 +83,8 @@ const AwardBadges = () => {
<Stack gap="md">
<Select
label="Select Player"
placeholder="Choose a player"
label={t`Select Player`}
placeholder={t`Choose a player`}
data={playerOptions}
value={selectedPlayerId}
onChange={setSelectedPlayerId}
@@ -99,7 +101,7 @@ const AwardBadges = () => {
loading={isAwarding}
size="md"
>
Award Badge
<Trans>Award Badge</Trans>
</Button>
</Group>
</Stack>
@@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
import { Badge, BadgeProgress } from "../types";
import { useMemo, useState } from "react";
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
interface BadgeShowcaseProps {
playerId: string;
@@ -297,7 +298,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Box>
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text size="sm" fw={500} c="dimmed">
Progress&nbsp;
<Trans>Progress&nbsp;</Trans>
</Text>
<Text size="sm" fw={600} c="dimmed">
{display.progressText}
@@ -18,6 +18,7 @@ import Sheet from '@/components/sheet/sheet';
import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro';
const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges();
@@ -41,7 +42,7 @@ const BadgeStatsTable = () => {
<Container px={0} size='md'>
<Stack align='center' gap='md' py='xl'>
<Title order={3} c='dimmed'>
No Badges Available
<Trans>No Badges Available</Trans>
</Title>
</Stack>
</Container>
@@ -83,9 +84,10 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
}) => {
const badgeSheet = useSheet();
const { user } = useAuth();
const { t } = useLingui();
const playerNamesBlurb = useMemo(() => {
if (earnedBadges.length === 0) return 'No players yet';
if (earnedBadges.length === 0) return t`No players yet`;
const currentUserHasBadge = earnedBadges.some(
(eb) => eb.player.id === user?.id
@@ -100,27 +102,34 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
: earnedBadges.slice(0, 3);
const names = displayPlayers.map((eb) => eb.player.first_name);
const namesList = names.join(', ');
if (currentUserHasBadge) {
const remaining = earnedBadges.length - 1 - names.length;
if (names.length === 0 && remaining === 0) {
return 'You';
return t`You`;
} else if (names.length === 0 && remaining > 0) {
return `You and ${remaining} other${remaining > 1 ? 's' : ''}`;
return remaining > 1
? t`You and ${remaining} others`
: t`You and 1 other`;
} else if (remaining > 0) {
return `You, ${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
return remaining > 1
? t`You, ${namesList} and ${remaining} others`
: t`You, ${namesList} and 1 other`;
} else {
return `You${names.length > 0 ? ` and ${names.join(', ')}` : ''}`;
return names.length > 0 ? t`You and ${namesList}` : t`You`;
}
} else {
const remaining = earnedBadges.length - names.length;
if (remaining > 0) {
return `${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
return remaining > 1
? t`${namesList} and ${remaining} others`
: t`${namesList} and 1 other`;
} else {
return names.join(', ');
return namesList;
}
}
}, [earnedBadges, user?.id]);
}, [earnedBadges, user?.id, t]);
const playersForList: Player[] = useMemo(() => {
return earnedBadges.map((eb) => ({
@@ -163,12 +172,12 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
).toFixed(0)}
%
</Text>
<Text c="dimmed" size='xs'>of players</Text>
<Text c="dimmed" size='xs'><Trans>of players</Trans></Text>
</Stack>
</Grid.Col>
</Grid>
</UnstyledButton>
<Sheet title={badge.name + ' Badge Holders'} {...badgeSheet.props}>
<Sheet title={t`${badge.name} Badge Holders`} {...badgeSheet.props}>
<PlayerList players={playersForList} />
</Sheet>
{!isLastRow && <Divider />}
@@ -6,6 +6,7 @@ import MatchDock from "./match-dock";
import useAppShellHeight from "@/hooks/use-appshell-height";
import { Match } from "@/features/matches/types";
import styles from "./styles.module.css";
import { Trans } from "@lingui/react/macro";
interface BracketViewProps {
bracket: BracketData;
@@ -95,14 +96,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
>
<div>
<Text fw={600} size="md" m={16}>
Winners Bracket
<Trans>Winners Bracket</Trans>
</Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
</div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
<Trans>Losers Bracket</Trans>
</Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
</div>
+21 -19
View File
@@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
import { getGroupLabel } from "../utils/group-label";
import styles from "./styles.module.css";
import { Trans, useLingui } from "@lingui/react/macro";
interface MatchCardProps {
match: Match;
@@ -42,6 +43,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const queryClient = useQueryClient();
const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const { t, i18n } = useLingui();
const canTap = !!(onTap && match.home && match.away);
@@ -59,9 +61,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.home_cups !== undefined &&
match.away_cups !== undefined &&
match.home_cups > match.away_cups,
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
groupLabel: !match.home && match.home_seed ? getGroupLabel(i18n, match.home_seed, groupConfig) : undefined,
}),
[match, orders, groupConfig]
[match, orders, groupConfig, i18n]
);
const awaySlot = useMemo(
() => ({
@@ -75,9 +77,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.away_cups !== undefined &&
match.home_cups !== undefined &&
match.away_cups > match.home_cups,
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
groupLabel: !match.away && match.away_seed ? getGroupLabel(i18n, match.away_seed, groupConfig) : undefined,
}),
[match, orders, groupConfig]
[match, orders, groupConfig, i18n]
);
const showToolbar = useMemo(
@@ -95,7 +97,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const start = useServerMutation({
mutationFn: startMatch,
successMessage: "Match started!",
successMessage: t`Match started!`,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
@@ -112,12 +114,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
editSheet.close();
if (data?.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
t`Downstream matches were reset because the correction changed who advances.`
);
}
if (data?.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
t`Group result changed after the bracket was seeded — reseed the knockout stage if needed.`
);
}
},
@@ -186,7 +188,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const handleSpeakerClick = useCallback(async () => {
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
await speak(t`${match.home?.name || t({ message: "Home", context: "match team" })} vs. ${match.away?.name || t({ message: "Away", context: "match team" })}`);
return;
}
@@ -196,14 +198,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await speak(t`versus`);
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
await speak(t`have fun, good luck!`);
} catch (error) {
console.warn('Walkout sequence error:', error);
await speak(`${match.home.name} vs. ${match.away.name}`);
await speak(t`${match.home.name} vs. ${match.away.name}`);
}
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
@@ -221,10 +223,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await speak(t`versus`);
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
await speak(t`have fun, good luck!`);
} catch (error) {
console.warn('Auto-walkout sequence error:', error);
}
@@ -283,7 +285,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}
aria-label={
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
? t`Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{
@@ -315,7 +317,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
c="dimmed"
fw="bold"
>
* If necessary
<Trans>* If necessary</Trans>
</Text>
)}
@@ -331,7 +333,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup"
aria-label={t`Announce matchup`}
>
<SpeakerHighIcon size={12} />
</ActionIcon>
@@ -349,7 +351,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
h="100%"
radius="sm"
ml={-4}
aria-label="Start match"
aria-label={t`Start match`}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
@@ -371,7 +373,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
h="100%"
radius="sm"
ml={-4}
aria-label="Edit match score"
aria-label={t`Edit match score`}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
@@ -383,7 +385,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
)}
</Flex>
<Sheet title="Edit Match" {...editSheet.props}>
<Sheet title={t`Edit Match`} {...editSheet.props}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
+11 -6
View File
@@ -23,6 +23,7 @@ import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head
import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { Trans, useLingui } from "@lingui/react/macro";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -81,6 +82,7 @@ const TeamRow = ({
const MatchDock = ({ match, onClose }: MatchDockProps) => {
const reduceMotion = useReducedMotion();
const h2hSheet = useSheet();
const { t } = useLingui();
const hasPrivate = match?.home?.private || match?.away?.private;
const ended = match?.status === "ended";
const started = match?.status === "started";
@@ -118,14 +120,17 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
/>
)}
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
Match {match.order} · Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
{match.is_losers_bracket ? (
<Trans>Match {match.order} · Round {match.round + 1} (Losers)</Trans>
) : (
<Trans>Match {match.order} · Round {match.round + 1}</Trans>
)}
</Text>
</Group>
<CloseButton
size="sm"
onClick={onClose}
aria-label="Close match actions"
aria-label={t`Close match actions`}
/>
</Group>
@@ -165,12 +170,12 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Suspense>
</Box>
{!hasPrivate && (
<Tooltip label="Head to Head" withArrow position="top">
<Tooltip label={t`Head to Head`} withArrow position="top">
<ActionIcon
variant="subtle"
size="sm"
onClick={h2hSheet.open}
aria-label="View head-to-head"
aria-label={t`View head-to-head`}
w={40}
style={{ flexShrink: 0 }}
>
@@ -207,7 +212,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</AnimatePresence>
{match?.home && match?.away && h2hSheet.isOpen && (
<Sheet title="Head to Head" {...h2hSheet.props}>
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
<TeamHeadToHeadSheet
team1={match.home}
team2={match.away}
+22 -20
View File
@@ -1,6 +1,7 @@
import { Button, TextInput, Stack, Group, Text, Flex, Divider, NumberInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { Match } from "@/features/matches/types";
import { Trans, useLingui } from "@lingui/react/macro";
interface MatchFormProps {
match: Match;
@@ -19,6 +20,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
onCancel,
loading = false,
}) => {
const { t } = useLingui();
const form = useForm({
initialValues: {
home_cups: match.home_cups || 10,
@@ -27,42 +29,42 @@ export const MatchForm: React.FC<MatchFormProps> = ({
},
validate: {
home_cups: (value, values) => {
if (value === null || value === undefined) return "Home cups is required";
if (value === null || value === undefined) return t`Home cups is required`;
if (values.ot_count > 0) return null;
const homeCups = Number(value);
const awayCups = Number(values.away_cups);
if (homeCups !== 10 && awayCups !== 10) {
return "At least one team must have 10 cups";
return t`At least one team must have 10 cups`;
}
if (homeCups === 10 && awayCups === 10) {
return "Both teams cannot have 10 cups";
return t`Both teams cannot have 10 cups`;
}
return null;
},
away_cups: (value, values) => {
if (value === null || value === undefined) return "Away cups is required";
if (value === null || value === undefined) return t`Away cups is required`;
if (values.ot_count > 0) return null;
const awayCups = Number(value);
const homeCups = Number(values.home_cups);
if (homeCups !== 10 && awayCups !== 10) {
return "At least one team must have 10 cups";
return t`At least one team must have 10 cups`;
}
if (homeCups === 10 && awayCups === 10) {
return "Both teams cannot have 10 cups";
return t`Both teams cannot have 10 cups`;
}
return null;
},
ot_count: (value) =>
value === null || value === undefined
? "Overtime count is required"
? t`Overtime count is required`
: null,
},
transformValues: (values) => ({
@@ -85,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
<Group gap="xs">
<Stack gap={0}>
<Text fw={500} size="sm">
{match.home?.name} Cups
<Trans>{match.home?.name} Cups</Trans>
</Text>
{
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
@@ -108,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
<Group gap="xs">
<Stack gap={0}>
<Text fw={500} size="sm">
{match.away?.name} Cups
<Trans>{match.away?.name} Cups</Trans>
</Text>
{
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
@@ -130,7 +132,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
<Group gap="xs">
<Text fw={500} size="sm">
OT Count
<Trans>OT Count</Trans>
</Text>
<TextInput
ml='auto'
@@ -147,7 +149,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
<Stack mt="md">
<Button type="submit" loading={loading}>
Update Match
<Trans>Update Match</Trans>
</Button>
<Button
variant="subtle"
@@ -155,7 +157,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
onClick={onCancel}
disabled={loading}
>
Cancel
<Trans>Cancel</Trans>
</Button>
</Stack>
</Stack>
@@ -12,6 +12,7 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form";
import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro";
const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
@@ -30,6 +31,7 @@ interface MatchReportProps {
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const { user } = useAuth();
const formSheet = useSheet();
const { t } = useLingui();
const [acknowledgedReport, setAcknowledgedReport] = useState<string | null>(
null
);
@@ -73,15 +75,15 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const report = useReportMatchScore(user?.id, {
onSuccess: (data: any) => {
if (data?.finalized) {
toast.success("Score confirmed 🍻");
toast.success(t`Score confirmed 🍻`);
if (data.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
t`Downstream matches were reset because the correction changed who advances.`
);
}
if (data.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
t`Group result changed after the bracket was seeded — reseed the knockout stage if needed.`
);
}
}
@@ -92,12 +94,12 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
onSuccess: (data: any) => {
if (data?.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
t`Downstream matches were reset because the correction changed who advances.`
);
}
if (data?.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
t`Group result changed after the bracket was seeded — reseed the knockout stage if needed.`
);
}
},
@@ -109,21 +111,21 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
(data: { home_cups: number; away_cups: number; ot_count: number }) => {
report.mutate({ data: { ...data, matchId: match.id } });
formSheet.close();
toast.success("Score submitted — waiting for the other team");
toast.success(t`Score submitted — waiting for the other team`);
},
[report, match.id, formSheet.close]
[report, match.id, formSheet.close, t]
);
const submitConfirm = useCallback(() => {
confirm.mutate({ data: { matchId: match.id } });
formSheet.close();
toast.success("Score confirmed 🍻");
}, [confirm, match.id, formSheet.close]);
toast.success(t`Score confirmed 🍻`);
}, [confirm, match.id, formSheet.close, t]);
const submitClear = useCallback(() => {
clear.mutate({ data: { matchId: match.id } });
toast.success("Report cleared");
}, [clear, match.id]);
toast.success(t`Report cleared`);
}, [clear, match.id, t]);
const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature);
@@ -143,7 +145,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const formSheetEl = (
<Sheet
title={sheetTakenOver ? "Confirm Score" : "Report Score"}
title={sheetTakenOver ? t`Confirm Score` : t`Report Score`}
{...formSheet.props}
>
{sheetTakenOver ? (
@@ -152,16 +154,16 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
variant="light"
color="yellow"
icon={<LightningIcon size={18} weight="fill" />}
title={`${teamName(reportingTeam) ?? "The other team"} just reported a score`}
title={t`${teamName(reportingTeam) ?? t`The other team`} just reported a score`}
>
Does this look right?
<Trans>Does this look right?</Trans>
</Alert>
<Paper withBorder p="md">
<Group justify="center" gap="md" wrap="nowrap">
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ta="center" lineClamp={1}>
{teamName(match.home) ?? "Home"}
{teamName(match.home) ?? t({ message: "Home", context: "match team" })}
</Text>
<Text fz={34} fw={700} lh={1.2}>
{match.reported_home_cups}
@@ -172,7 +174,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
</Text>
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ta="center" lineClamp={1}>
{teamName(match.away) ?? "Away"}
{teamName(match.away) ?? t({ message: "Away", context: "match team" })}
</Text>
<Text fz={34} fw={700} lh={1.2}>
{match.reported_away_cups}
@@ -188,7 +190,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
onClick={submitConfirm}
loading={confirm.isPending}
>
Confirm {scoreText}
<Trans>Confirm {scoreText}</Trans>
</Button>
<Button
fullWidth
@@ -196,7 +198,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
onClick={dismissTakeover}
disabled={confirm.isPending}
>
Not right — enter our score
<Trans>Not right — enter our score</Trans>
</Button>
</Stack>
</Stack>
@@ -220,7 +222,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
onClick={openForm}
loading={report.isPending}
>
Report Score
<Trans>Report Score</Trans>
</Button>
{formSheetEl}
</>
@@ -237,11 +239,13 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
gap={compact ? 4 : "xs"}
>
<Text size="xs" c="dimmed" lineClamp={2} style={{ minWidth: 0 }}>
Waiting for {teamName(opposingTeam) ?? "the other team"} to confirm{" "}
·{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
<Trans>
Waiting for {teamName(opposingTeam) ?? t`the other team`} to confirm{" "}
·{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
</Trans>
</Text>
<Button
size={size}
@@ -251,7 +255,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
loading={clear.isPending}
style={{ flexShrink: 0 }}
>
Cancel
<Trans>Cancel</Trans>
</Button>
</Flex>
);
@@ -262,10 +266,12 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
<>
<Stack gap={6}>
<Text size="xs" c="dimmed" lineClamp={2}>
{teamName(reportingTeam) ?? "Opponent"} reported{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
<Trans>
{teamName(reportingTeam) ?? t`Opponent`} reported{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
</Trans>
</Text>
<Group gap="xs" wrap="nowrap">
<Button
@@ -275,7 +281,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
loading={confirm.isPending}
style={{ flex: 1 }}
>
Confirm
<Trans>Confirm</Trans>
</Button>
<Button
size={size}
@@ -283,7 +289,7 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
onClick={openForm}
disabled={confirm.isPending}
>
Not right
<Trans>Not right</Trans>
</Button>
</Group>
</Stack>
@@ -294,7 +300,9 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
return (
<Text size="xs" c="dimmed" lineClamp={2}>
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? "a team"}
<Trans>
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? t`a team`}
</Trans>
</Text>
);
}
@@ -5,6 +5,7 @@ import { SeedBadge } from "./seed-badge";
import { TeamInfo } from "@/features/teams/types";
import AnimatedScore from "@/features/matches/components/animated-score";
import classes from "./match-slot.module.css";
import { Trans } from "@lingui/react/macro";
export type MatchSlotState = "winner" | "correct" | "incorrect";
@@ -97,11 +98,15 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
</Text>
) : from ? (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{from_loser ? "Loser" : "Winner"} of Match {from}
{from_loser ? (
<Trans>Loser of Match {from}</Trans>
) : (
<Trans>Winner of Match {from}</Trans>
)}
</Text>
) : (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
TBD
<Trans>TBD</Trans>
</Text>
)}
</Flex>
@@ -1,4 +1,5 @@
import { Flex, Text, Select, Card } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
interface Team {
id: string;
@@ -11,9 +12,10 @@ interface SeedListProps {
}
export function SeedList({ teams, onSeedChange }: SeedListProps) {
const { t } = useLingui();
const seedOptions = teams.map((_, index) => ({
value: index.toString(),
label: `Seed ${index + 1}`,
label: t`Seed ${index + 1}`,
}));
return (
+19 -7
View File
@@ -1,9 +1,25 @@
import { msg } from "@lingui/core/macro";
import type { I18n } from "@lingui/core";
export interface GroupConfig {
num_groups: number;
advance_per_group: number;
}
const wildcardMsg = msg`Wildcard {n}`;
const groupRankMsg = msg`{group} {rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`;
export const formatWildcardLabel = (i18n: I18n, n: number): string =>
i18n._({ ...wildcardMsg, values: { n } });
export const formatGroupRankLabel = (
i18n: I18n,
group: string,
rank: number
): string => i18n._({ ...groupRankMsg, values: { group, rank } });
export function getGroupLabel(
i18n: I18n,
seed: number | undefined,
groupConfig: GroupConfig | undefined
): string | undefined {
@@ -19,7 +35,7 @@ export function getGroupLabel(
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
return formatWildcardLabel(i18n, wildcardNumber);
}
const pairIndex = Math.floor((seed - 1) / 2);
@@ -31,19 +47,15 @@ export function getGroupLabel(
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix =
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
return `${groupName} ${rankSuffix}`;
return formatGroupRankLabel(i18n, groupName, rank);
} else {
const groupIndex = (pairIndex + 1) % numGroups;
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix =
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
return `${groupName} ${rankSuffix}`;
return formatGroupRankLabel(i18n, groupName, rank);
}
}
+3 -1
View File
@@ -1,13 +1,15 @@
import { UnstyledButton } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
import { useLingui } from "@lingui/react/macro"
const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
const router = useRouter()
const { t } = useLingui()
return (
<UnstyledButton
aria-label='Go back'
aria-label={t`Go back`}
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
onClick={() => router.history.back()}
pos='absolute'
+9 -2
View File
@@ -1,8 +1,15 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { useLingui } from "@lingui/react";
import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button";
const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
const { i18n } = useLingui();
const resolvedTitle =
typeof title === "string" || title === undefined
? title
: i18n._({ ...title, values: titleValues });
return (
<AppShell.Header
id='app-header'
@@ -15,7 +22,7 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
{ withBackButton && <BackButton /> }
<Flex justify='center' px='md' mt={8}>
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
{title?.toLocaleUpperCase()}
{resolvedTitle?.toLocaleUpperCase()}
</Title>
</Flex>
</AppShell.Header>
+5 -2
View File
@@ -1,5 +1,6 @@
import { AuthProvider } from "@/contexts/auth-context"
import { SpotifyProvider } from "@/contexts/spotify-context"
import { LinguiProvider } from "@/lib/i18n/provider"
import MantineProvider from "@/lib/mantine/mantine-provider"
//import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
//import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
@@ -8,7 +9,8 @@ import { Toaster } from "sonner"
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<AuthProvider>
<LinguiProvider>
<AuthProvider>
<SpotifyProvider>
<MantineProvider>
{/*<TanStackDevtools
@@ -31,7 +33,8 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
{children}
</MantineProvider>
</SpotifyProvider>
</AuthProvider>
</AuthProvider>
</LinguiProvider>
)
}
@@ -1,6 +1,7 @@
import { UnstyledButton } from "@mantine/core"
import { GearIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router"
import { useLingui } from "@lingui/react/macro"
import { memo } from "react";
interface SettingButtonProps {
@@ -11,10 +12,11 @@ interface SettingButtonProps {
const SettingsButton = ({ to }: SettingButtonProps) => {
const navigate = useNavigate();
const { t } = useLingui();
return (
<UnstyledButton
aria-label='Settings'
aria-label={t`Settings`}
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
onClick={() => navigate({ to })}
pos='absolute'
+12 -8
View File
@@ -1,27 +1,30 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
export const useLinks = (userId: string | undefined, roles: string[]) =>
useMemo(() => {
export const useLinks = (userId: string | undefined, roles: string[]) => {
const { t } = useLingui();
return useMemo(() => {
const links = [
{
label: 'Home',
label: t`Home`,
href: '/',
Icon: HouseIcon
},
{
label: 'Statistics',
label: t`Statistics`,
href: '/stats',
Icon: RankingIcon
},
{
label: 'Tournaments',
label: t`Tournaments`,
href: '/tournaments',
Icon: TrophyIcon,
exclude: ['/admin/tournaments']
},
{
label: 'Profile',
label: t`Profile`,
href: `/profile/${userId}`,
Icon: UserCircleIcon,
include: ['/settings']
@@ -30,11 +33,12 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
if (roles.includes('Admin')) {
links.push({
label: 'Admin',
label: t`Admin`,
href: '/admin',
Icon: ShieldIcon
})
}
return links;
}, [userId, roles]);
}, [userId, roles, t]);
};
+2 -2
View File
@@ -16,14 +16,14 @@ const useRouterConfig = () => {
match?.loaderData && 'header' in match.loaderData
);
const headerConfig = matchesWithHeader.reduce((acc, match) => {
const headerConfig = matchesWithHeader.reduce<HeaderConfig>((acc, match) => {
const loaderData = match?.loaderData;
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
const header = loaderData.header;
if (header && typeof header === 'object') {
return {
...acc,
...header,
...(header as HeaderConfig),
}
}
}
+16 -2
View File
@@ -1,8 +1,22 @@
/**
* Serializable subset of Lingui's MessageDescriptor — header config passes
* through route loaders/beforeLoad, whose results must be serializable.
* msg`` descriptors are assignable to this shape.
*/
interface HeaderTitleMessage {
id: string;
message?: string;
comment?: string;
}
interface HeaderConfig {
title?: string;
/** Plain strings render as-is (data-derived names); use a msg`` descriptor for translatable titles. */
title?: string | HeaderTitleMessage;
/** ICU values when title is a descriptor, e.g. msg`Manage {name}` + { name }. */
titleValues?: Record<string, string | number>;
withBackButton?: boolean;
collapsed?: boolean;
settingsLink?: string;
}
export type { HeaderConfig };
export type { HeaderConfig, HeaderTitleMessage };
@@ -2,9 +2,11 @@ import { useState } from 'react';
import { Flex, PinInput, Title, Text, Stack, LoadingOverlay } from '@mantine/core';
import useConsumeCode from '../hooks/use-consume-code';
import { useSearch } from '@tanstack/react-router';
import { Trans, useLingui } from '@lingui/react/macro';
const CodePrompt = () => {
const { number } = useSearch({ from: '/login' });
const { t } = useLingui();
const [isWrong, setIsWrong] = useState(false);
const [code, setCode] = useState('');
@@ -24,10 +26,10 @@ const CodePrompt = () => {
return (
<Flex direction="column" p={10} w='max-content' m='auto'>
<Title order={4}>Enter Verification Code</Title>
<Text size='xs'c="dimmed" mb={5}>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Text>
<Title order={4}><Trans>Enter Verification Code</Trans></Title>
<Text size='xs'c="dimmed" mb={5}><Trans>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Trans></Text>
<Stack justify='center' p={10} gap={2} pos='relative'>
<PinInput aria-label="One time code"
<PinInput aria-label={t`One time code`}
value={code}
error={isWrong}
onChange={handleChange}
@@ -38,7 +40,7 @@ const CodePrompt = () => {
type='number'
/>
<LoadingOverlay visible={isPending} overlayProps={{ blur: 0.375, radius: 'md', backgroundOpacity: 0.35 }} />
{isWrong && <Text c='red' size='xs'>Incorrect code</Text>}
{isWrong && <Text c='red' size='xs'><Trans>Incorrect code</Trans></Text>}
</Stack>
</Flex>
)
+2 -1
View File
@@ -5,6 +5,7 @@ import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
import { Trans } from '@lingui/react/macro';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
@@ -51,7 +52,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
>
<TrophyIcon size={32} />
</GlitchAvatar>
<Title order={1} ta='center'>Welcome to FLXN</Title>
<Title order={1} ta='center'><Trans>Welcome to FLXN</Trans></Title>
</Stack>
{children}
</Paper>
+10 -8
View File
@@ -1,9 +1,11 @@
import { TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { Trans, useLingui } from "@lingui/react/macro";
import useCreateUser from "../hooks/use-create-user";
import Button from "@/components/button";
const NamePrompt = () => {
const NamePrompt = () => {
const { t } = useLingui();
const form = useForm({
initialValues: {
first_name: '',
@@ -11,12 +13,12 @@ const NamePrompt = () => {
},
validate: {
first_name: (value) => {
if (value.length === 0) return 'First name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
if (value.length === 0) return t`First name is required`
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return t`First name must be 3-20 characters long and contain only letters`
},
last_name: (value) => {
if (value.length === 0) return 'Last name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
if (value.length === 0) return t`Last name is required`
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return t`Last name must be 3-20 characters long and contain only letters`
}
}
})
@@ -32,17 +34,17 @@ const NamePrompt = () => {
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="first_name"
label='First Name'
label={t`First Name`}
key={form.key('first_name')}
{...form.getInputProps('first_name')}
/>
<TextInput
id="last_name"
label='Last Name'
label={t`Last Name`}
key={form.key('last_name')}
{...form.getInputProps('last_name')}
/>
<Button loading={isPending} type='submit' mt='10px' variant='filled'>Create Account</Button>
<Button loading={isPending} type='submit' mt='10px' variant='filled'><Trans>Create Account</Trans></Button>
</form>
)
}
@@ -1,17 +1,19 @@
import { Button } from "@mantine/core";
import PhoneNumberInput from "@/components/phone-number-input";
import { useForm } from "@mantine/form";
import { Trans, useLingui } from "@lingui/react/macro";
import useCreateCode from "../hooks/use-create-code";
const PhonePrompt = () => {
const { t } = useLingui();
const form = useForm({
initialValues: {
number: ''
},
validate: {
number: (value) => {
if (value.length === 0) return 'Phone number is required'
if (value.length !== 10) return 'Phone number must be 10 digits'
if (value.length === 0) return t`Phone number is required`
if (value.length !== 10) return t`Phone number must be 10 digits`
}
}
})
@@ -27,11 +29,11 @@ const PhonePrompt = () => {
<form onSubmit={form.onSubmit(handleSubmit)}>
<PhoneNumberInput
id="number"
label='Enter your phone number'
label={t`Enter your phone number`}
key={form.key('number')}
{...form.getInputProps('number')}
/>
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}>Send Code</Button>
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}><Trans>Send Code</Trans></Button>
</form>
)
}
@@ -1,6 +1,7 @@
import Button from "@/components/button";
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
@@ -14,7 +15,7 @@ const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) =>
<Center>
<ChalkboardTeacherIcon size='3rem' />
</Center>
<Text size='md' fw={600}>Returning Player</Text>
<Text size='md' fw={600}><Trans>Returning Player</Trans></Text>
</SimpleGrid>
</Button>
};
@@ -1,6 +1,7 @@
import { useState, FormEventHandler, useMemo } from 'react';
import { ArrowLeftIcon } from '@phosphor-icons/react';
import { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
import { Trans, useLingui } from '@lingui/react/macro';
import ExistingPlayerButton from './existing-player-button';
import NewPlayerButton from './new-player-button';
import { Player } from '@/features/players/types';
@@ -15,6 +16,7 @@ enum PlayerPromptStage {
}
const PlayerPrompt = () => {
const { t } = useLingui();
const [stage, setStage] = useState<PlayerPromptStage>();
const playersQuery = useUnassociatedPlayers();
const { mutate: createUser, isPending } = useCreateUser();
@@ -41,7 +43,7 @@ const PlayerPrompt = () => {
// check if player already exists
if (!!parsedPlayers?.find(p => p.label === value)) {
toast.error("Player already exists");
toast.error(t`Player already exists`);
return;
}
@@ -56,7 +58,7 @@ const PlayerPrompt = () => {
setError('');
createUser(player.id!);
} else {
setError('You must select a player from the dropdown. If you don\'t see yourself, please go back and select \'New Player\'');
setError(t`You must select a player from the dropdown. If you don't see yourself, please go back and select 'New Player'`);
}
}
@@ -76,8 +78,8 @@ const PlayerPrompt = () => {
if (!stage) {
return <>
<Title order={3}>Have you played before?</Title>
<Text size='xs' mb='sm'>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Text>
<Title order={3}><Trans>Have you played before?</Trans></Title>
<Text size='xs' mb='sm'><Trans>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Trans></Text>
<Flex justify='space-around'>
<ExistingPlayerButton onClick={() => setStage(PlayerPromptStage.returning)} />
<Divider orientation='vertical' variant="dashed" />
@@ -88,7 +90,7 @@ const PlayerPrompt = () => {
return <>
<UnstyledButton
aria-label="Go back"
aria-label={t`Go back`}
onClick={() => setStage(undefined)}
style={{
position: 'absolute',
@@ -105,23 +107,23 @@ const PlayerPrompt = () => {
<>
<form onSubmit={formSubmitHandler(handleNewPlayerSubmit)}>
<TextInput
label='Enter your name'
placeholder='Salah Atiyeh'
label={t`Enter your name`}
placeholder={t`Salah Atiyeh`}
value={value}
onChange={handleNewPlayerChange}
/>
<Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
<Button type='submit' mt='10px' color='green' variant='filled'><Trans>Submit</Trans></Button>
</form>
</> :
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
<Autocomplete
label='Enter your name'
placeholder='Salah Atiyeh'
label={t`Enter your name`}
placeholder={t`Salah Atiyeh`}
data={autocompleteOptions}
onChange={handleReturningPlayerChange}
error={error}
/>
<Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
<Button type='submit' mt='10px' color='green' variant='filled'><Trans>Submit</Trans></Button>
</form>
}
</>
@@ -1,6 +1,7 @@
import Button from "@/components/button";
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { UserPlusIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
@@ -14,7 +15,7 @@ const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
<Center>
<UserPlusIcon size='3rem' />
</Center>
<Text size='md' fw={600}>New Player</Text>
<Text size='md' fw={600}><Trans>New Player</Trans></Text>
</SimpleGrid>
</Button>
};
+8 -6
View File
@@ -4,11 +4,13 @@ import { fetchMe } from "@/features/players/server";
import { useNavigate } from "@tanstack/react-router";
import toast from '@/lib/sonner'
import { playerKeys } from "@/features/players/queries";
import { useLingui } from "@lingui/react/macro";
const useConsumeCode = (onWrongCode: () => void) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useLingui();
return useMutation({
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
onSuccess: async (data) => {
@@ -18,18 +20,18 @@ const useConsumeCode = (onWrongCode: () => void) => {
navigate({ to: '/login', search: { stage: 'name' } });
} else {
queryClient.setQueryData(playerKeys.auth, response.data);
toast.success('Successfully logged in. Welcome back!');
toast.success(t`Successfully logged in. Welcome back!`);
navigate({ to: '/' })
}
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode();
} else if (data.status === 'EXPIRED_USER_INPUT_CODE_ERROR') {
toast.error('Code has expired. Please request a new code.');
toast.error(t`Code has expired. Please request a new code.`);
} else if (data.status === "RESTART_FLOW_ERROR") {
toast.error('Too many failed attempts. Please try again.');
toast.error(t`Too many failed attempts. Please try again.`);
navigate({ to: '/login', search: { stage: undefined, number: undefined } });
} else {
toast.error('Unknown error. Please try again later.');
toast.error(t`Unknown error. Please try again later.`);
}
return data;
@@ -38,7 +40,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error("Unknown error. Please try again later.");
toast.error(t`Unknown error. Please try again later.`);
}
},
});
+5 -3
View File
@@ -2,15 +2,17 @@ import { createCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import toast from '@/lib/sonner'
import { useLingui } from "@lingui/react/macro";
const useCreateCode = () => {
const navigate = useNavigate();
const { t } = useLingui();
return useMutation({
mutationFn: (phoneNumber: string) => createCode({ phoneNumber: '+1' + phoneNumber }),
onSuccess: (data, phoneNumber) => {
if (data.status === 'OK') {
toast.success('Code sent successfully');
toast.success(t`Code sent successfully`);
navigate({ to: '/login', search: { stage: 'code', number: phoneNumber } });
} else {
toast.error(data.reason);
@@ -20,7 +22,7 @@ const useCreateCode = () => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to send a one-time passcode. Please try again later.');
toast.error(t`An unexpected error occurred when trying to send a one-time passcode. Please try again later.`);
}
},
});
+4 -2
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
import { associatePlayer, createPlayer } from "@/features/players/server";
import { playerKeys } from "@/features/players/queries";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
@@ -7,13 +8,14 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateUser = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useLingui();
return useServerMutation({
mutationFn: (data: { first_name: string, last_name: string } | string) =>
typeof data === 'string' ?
associatePlayer({ data })
: createPlayer({ data }),
successMessage: 'Account created successfully!',
successMessage: t`Account created successfully!`,
onSuccess: (data) => {
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
...old,
@@ -9,6 +9,7 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro";
interface MatchCardProps {
match: Match;
@@ -18,6 +19,7 @@ interface MatchCardProps {
const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const navigate = useNavigate();
const h2hSheet = useSheet();
const { t } = useLingui();
const isHomeWin = match.home_cups > match.away_cups;
const isAwayWin = match.away_cups > match.home_cups;
const isStarted = match.status === "started";
@@ -71,19 +73,19 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
<>
<Text c="dimmed">-</Text>
<Text size="xs" c="dimmed">
Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
<Trans>Round {match.round + 1}</Trans>
{match.is_losers_bracket && <Trans> (Losers)</Trans>}
</Text>
</>
)}
</Group>
{match.home && match.away && !hideH2H && !hasPrivate && (
<Tooltip label="Head to Head" withArrow position="left">
<Tooltip label={t`Head to Head`} withArrow position="left">
<ActionIcon
variant="subtle"
size="sm"
onClick={handleH2HClick}
aria-label="View head-to-head"
aria-label={t`View head-to-head`}
w={40}
>
<Group style={{ position: 'relative', width: 27.5, height: 16 }}>
@@ -226,7 +228,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
{match.home && match.away && !hideH2H && h2hSheet.isOpen && (
<Sheet
title="Head to Head"
title={t`Head to Head`}
{...h2hSheet.props}
>
<TeamHeadToHeadSheet team1={match.home} team2={match.away} isOpen={h2hSheet.props.opened} />
@@ -2,6 +2,7 @@ import { Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { Match } from "../types";
import MatchCard from "./match-card";
import { Trans } from "@lingui/react/macro";
interface MatchListProps {
matches: Match[];
@@ -29,7 +30,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
<Stack p="md" gap="sm">
{isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md">
Matches for regionals are unordered
<Trans>Matches for regionals are unordered</Trans>
</Text>
)}
{filteredMatches.map((match, index) => (
@@ -5,6 +5,7 @@ import { useMemo, useEffect, useState, Suspense } from "react";
import { CrownIcon } from "@phosphor-icons/react";
import MatchList from "./match-list";
import TeamHeadToHeadSkeleton from "./team-head-to-head-skeleton";
import { Trans, Plural } from "@lingui/react/macro";
interface TeamHeadToHeadSheetProps {
team1: TeamInfo;
@@ -87,7 +88,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
if (isLoading) {
return (
<Stack p="md" gap="md">
<Text size="sm" c="dimmed" ta="center">Loading...</Text>
<Text size="sm" c="dimmed" ta="center"><Trans>Loading...</Trans></Text>
</Stack>
);
}
@@ -96,7 +97,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
return (
<Stack p="md" gap="md">
<Text size="sm" c="dimmed" ta="center">
These teams have not faced each other yet.
<Trans>These teams have not faced each other yet.</Trans>
</Text>
</Stack>
);
@@ -111,7 +112,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
<Stack gap="sm">
<Group justify="center" gap="xs">
<Text size="lg" fw={700}>{team1.name}</Text>
<Text size="sm" c="dimmed">vs</Text>
<Text size="sm" c="dimmed"><Trans>vs</Trans></Text>
<Text size="lg" fw={700}>{team2.name}</Text>
</Group>
@@ -131,32 +132,32 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
<Group justify="center" gap="xs">
<CrownIcon size={16} weight="fill" color="gold" />
<Text size="xs" c="dimmed">
{leader.name} leads the series
<Trans>{leader.name} leads the series</Trans>
</Text>
</Group>
)}
{!leader && totalMatches > 0 && (
<Text size="xs" c="dimmed" ta="center">
Series is tied
<Trans>Series is tied</Trans>
</Text>
)}
</Stack>
</Paper>
<Stack gap={0}>
<Text size="sm" fw={600} px="md" mb="xs">Stats Comparison</Text>
<Text size="sm" fw={600} px="md" mb="xs"><Trans>Stats Comparison</Trans></Text>
<Paper withBorder>
<Stack gap={0}>
<Group justify="space-between" px="md" py="sm">
<Group gap="xs">
<Text size="sm" fw={600}>{stats.team1CupsFor}</Text>
<Text size="xs" c="dimmed">cups</Text>
<Text size="xs" c="dimmed"><Trans>cups</Trans></Text>
</Group>
<Text size="xs" fw={500}>Total Cups</Text>
<Text size="xs" fw={500}><Trans>Total Cups</Trans></Text>
<Group gap="xs">
<Text size="xs" c="dimmed">cups</Text>
<Text size="xs" c="dimmed"><Trans>cups</Trans></Text>
<Text size="sm" fw={600}>{stats.team2CupsFor}</Text>
</Group>
</Group>
@@ -167,11 +168,11 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
<Text size="sm" fw={600}>
{totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'}
</Text>
<Text size="xs" c="dimmed">avg</Text>
<Text size="xs" c="dimmed"><Trans>avg</Trans></Text>
</Group>
<Text size="xs" fw={500}>Avg Cups/Match</Text>
<Text size="xs" fw={500}><Trans>Avg Cups/Match</Trans></Text>
<Group gap="xs">
<Text size="xs" c="dimmed">avg</Text>
<Text size="xs" c="dimmed"><Trans>avg</Trans></Text>
<Text size="sm" fw={600}>
{totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'}
</Text>
@@ -184,11 +185,11 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
<Text size="sm" fw={600}>
{!isNaN(stats.team1AvgMargin) ? stats.team1AvgMargin.toFixed(1) : '0.0'}
</Text>
<Text size="xs" c="dimmed">margin</Text>
<Text size="xs" c="dimmed"><Trans>margin</Trans></Text>
</Group>
<Text size="xs" fw={500}>Avg Win Margin</Text>
<Text size="xs" fw={500}><Trans>Avg Win Margin</Trans></Text>
<Group gap="xs">
<Text size="xs" c="dimmed">margin</Text>
<Text size="xs" c="dimmed"><Trans>margin</Trans></Text>
<Text size="sm" fw={600}>
{!isNaN(stats.team2AvgMargin) ? stats.team2AvgMargin.toFixed(1) : '0.0'}
</Text>
@@ -199,7 +200,9 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600} px="md">Match History ({totalMatches} match{totalMatches !== 1 ? 'es' : ''})</Text>
<Text size="sm" fw={600} px="md">
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
</Text>
<MatchList matches={matches} hideH2H />
</Stack>
</Stack>
@@ -1,6 +1,7 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core";
import { useState, useMemo } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
@@ -8,6 +9,7 @@ import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar";
const LeagueHeadToHead = () => {
const { t } = useLingui();
const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState("");
@@ -112,7 +114,7 @@ const LeagueHeadToHead = () => {
<Stack gap={4} align="center">
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 1
<Trans>Player 1</Trans>
</Text>
</Stack>
)}
@@ -120,7 +122,7 @@ const LeagueHeadToHead = () => {
<Center>
<Text size="xl" fw={700} c="dimmed">
VS
<Trans>VS</Trans>
</Text>
</Center>
@@ -168,7 +170,7 @@ const LeagueHeadToHead = () => {
<Stack gap={4} align="center">
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 2
<Trans>Player 2</Trans>
</Text>
</Stack>
)}
@@ -183,8 +185,8 @@ const LeagueHeadToHead = () => {
fullWidth
styles={{ label: { textTransform: "none" } }}
>
{activeStep === 1 && "Select first player"}
{activeStep === 2 && "Select second player"}
{activeStep === 1 && <Trans>Select first player</Trans>}
{activeStep === 2 && <Trans>Select second player</Trans>}
</Badge>
) : (
<Group justify="center">
@@ -198,7 +200,7 @@ const LeagueHeadToHead = () => {
}}
td="underline"
>
Clear both players
<Trans>Clear both players</Trans>
</Text>
</Group>
)}
@@ -206,7 +208,7 @@ const LeagueHeadToHead = () => {
</Paper>
<TextInput
placeholder="Search players"
placeholder={t`Search players`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
@@ -218,7 +220,7 @@ const LeagueHeadToHead = () => {
<Paper withBorder>
{filteredPlayers.length === 0 && (
<Text size="sm" c="dimmed" ta="center" py="xl">
{search ? `No players found matching "${search}"` : "No players available"}
{search ? t`No players found matching "${search}"` : t`No players available`}
</Text>
)}
@@ -259,7 +261,7 @@ const LeagueHeadToHead = () => {
</Stack>
{player1Id && player2Id && (
<Sheet title="Head to Head" {...h2hSheet.props}>
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
<PlayerHeadToHeadSheet
player1Id={player1Id}
player1Name={player1Name}
@@ -2,6 +2,7 @@ import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
import { usePlayerHeadToHead } from "@/features/matches/queries";
import { useMemo, useEffect, useState, Suspense } from "react";
import { CrownIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
import MatchList from "@/features/matches/components/match-list";
import PlayerHeadToHeadSkeleton from "./player-head-to-head-skeleton";
@@ -93,7 +94,7 @@ const PlayerHeadToHeadContent = ({
return (
<Stack p="md" gap="md">
<Text size="sm" c="dimmed" ta="center">
Loading...
<Trans>Loading...</Trans>
</Text>
</Stack>
);
@@ -103,7 +104,7 @@ const PlayerHeadToHeadContent = ({
return (
<Stack p="md" gap="md">
<Text size="sm" c="dimmed" ta="center">
These players have not faced each other yet.
<Trans>These players have not faced each other yet.</Trans>
</Text>
</Stack>
);
@@ -126,7 +127,7 @@ const PlayerHeadToHeadContent = ({
{player1Name}
</Text>
<Text size="sm" c="dimmed">
vs
<Trans>vs</Trans>
</Text>
<Text size="lg" fw={700}>
{player2Name}
@@ -159,14 +160,14 @@ const PlayerHeadToHeadContent = ({
<Group justify="center" gap="xs">
<CrownIcon size={16} weight="fill" color="gold" />
<Text size="xs" c="dimmed">
{leader} leads the series
<Trans>{leader} leads the series</Trans>
</Text>
</Group>
)}
{!leader && totalMatches > 0 && (
<Text size="xs" c="dimmed" ta="center">
Series is tied
<Trans>Series is tied</Trans>
</Text>
)}
</Stack>
@@ -174,7 +175,7 @@ const PlayerHeadToHeadContent = ({
<Stack gap={0}>
<Text size="sm" fw={600} px="md" mb="xs">
Stats Comparison
<Trans>Stats Comparison</Trans>
</Text>
<Paper withBorder>
@@ -185,15 +186,15 @@ const PlayerHeadToHeadContent = ({
{stats.player1CupsFor}
</Text>
<Text size="xs" c="dimmed">
cups
<Trans>cups</Trans>
</Text>
</Group>
<Text size="xs" fw={500}>
Total Cups
<Trans>Total Cups</Trans>
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
cups
<Trans>cups</Trans>
</Text>
<Text size="sm" fw={600}>
{stats.player2CupsFor}
@@ -210,15 +211,15 @@ const PlayerHeadToHeadContent = ({
: "0.0"}
</Text>
<Text size="xs" c="dimmed">
avg
<Trans>avg</Trans>
</Text>
</Group>
<Text size="xs" fw={500}>
Avg Cups/Match
<Trans>Avg Cups/Match</Trans>
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
avg
<Trans>avg</Trans>
</Text>
<Text size="sm" fw={600}>
{totalMatches > 0
@@ -237,15 +238,15 @@ const PlayerHeadToHeadContent = ({
: "0.0"}
</Text>
<Text size="xs" c="dimmed">
margin
<Trans>margin</Trans>
</Text>
</Group>
<Text size="xs" fw={500}>
Avg Win Margin
<Trans>Avg Win Margin</Trans>
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
margin
<Trans>margin</Trans>
</Text>
<Text size="sm" fw={600}>
{!isNaN(stats.player2AvgMargin)
@@ -260,7 +261,7 @@ const PlayerHeadToHeadContent = ({
<Stack gap="xs">
<Text size="sm" fw={600} px="md">
Match History ({totalMatches})
<Trans>Match History ({totalMatches})</Trans>
</Text>
<MatchList matches={matches} hideH2H />
</Stack>
@@ -27,6 +27,7 @@ import InfiniteScroll from "@/components/infinite-scroll";
import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro";
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
@@ -61,6 +62,7 @@ const StatCell = memo(({ label, value }: StatCellProps) => (
));
const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => {
const { t } = useLingui();
const viewportRef = useRef<HTMLDivElement>(null);
const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]);
@@ -103,11 +105,11 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
</Text>
<Text size="xs" c="dimmed" ta="right">
{stat.matches}
<Text span fw={800}>M</Text>
<Text span fw={800}><Trans>M</Trans></Text>
</Text>
<Text size="xs" c="dimmed" ta="right">
{stat.tournaments}
<Text span fw={800}>T</Text>
<Text span fw={800}><Trans>T</Trans></Text>
</Text>
</Group>
@@ -125,16 +127,16 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
}}
>
<Group gap='xs' wrap="nowrap">
<StatCell label="MMR" value={mmr.toFixed(1)} />
<StatCell label="W" value={stat.wins} />
<StatCell label="L" value={stat.losses} />
<StatCell label="W%" value={`${stat.win_percentage.toFixed(1)}%`} />
<StatCell label="AWM" value={stat.margin_of_victory?.toFixed(1) || 0} />
<StatCell label="ALM" value={stat.margin_of_loss?.toFixed(1) || 0} />
<StatCell label="AC" value={stat.avg_cups_per_match.toFixed(1)} />
<StatCell label="ACA" value={avg_cups_against?.toFixed(1) || 0} />
<StatCell label="CF" value={stat.total_cups_made} />
<StatCell label="CA" value={stat.total_cups_against} />
<StatCell label={t`MMR`} value={mmr.toFixed(1)} />
<StatCell label={t`W`} value={stat.wins} />
<StatCell label={t`L`} value={stat.losses} />
<StatCell label={t`W%`} value={`${stat.win_percentage.toFixed(1)}%`} />
<StatCell label={t`AWM`} value={stat.margin_of_victory?.toFixed(1) || 0} />
<StatCell label={t`ALM`} value={stat.margin_of_loss?.toFixed(1) || 0} />
<StatCell label={t`AC`} value={stat.avg_cups_per_match.toFixed(1)} />
<StatCell label={t`ACA`} value={avg_cups_against?.toFixed(1) || 0} />
<StatCell label={t`CF`} value={stat.total_cups_made} />
<StatCell label={t`CA`} value={stat.total_cups_against} />
</Group>
</ScrollArea>
</Stack>
@@ -170,6 +172,7 @@ const calculateMMR = (stat: PlayerStats): number => {
};
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const { t } = useLingui();
const { data: playerStats } = useAllPlayerStats(viewType);
const navigate = useNavigate();
const [search, setSearch] = useState("");
@@ -310,7 +313,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<ChartBarIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Stats Available
<Trans>No Stats Available</Trans>
</Title>
</Stack>
);
@@ -320,10 +323,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<Container size="100%" px={0}>
<Stack gap="xs">
<Text px="md" size="10px" lh={0} c="dimmed">
Showing {filteredAndSortedStats.length} of {playerStats.length} players
<Trans>Showing {filteredAndSortedStats.length} of {playerStats.length} players</Trans>
</Text>
<TextInput
placeholder="Search players"
placeholder={t`Search players`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
@@ -334,13 +337,13 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<Group px="md" justify="space-between" align="center">
<Group gap="xs" w="100%">
<div></div>
<Text ml='auto' size="xs" c="dimmed">Sort:</Text>
<Text ml='auto' size="xs" c="dimmed"><Trans>Sort:</Trans></Text>
<UnstyledButton
onClick={() => handleSort("mmr")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "var(--mantine-color-text)" : "dimmed"}>
MMR
<Trans>MMR</Trans>
</Text>
{getSortIcon("mmr")}
</UnstyledButton>
@@ -350,7 +353,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "var(--mantine-color-text)" : "dimmed"}>
Wins
<Trans>Wins</Trans>
</Text>
{getSortIcon("wins")}
</UnstyledButton>
@@ -360,80 +363,80 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "var(--mantine-color-text)" : "dimmed"}>
Matches
<Trans>Matches</Trans>
</Text>
{getSortIcon("matches")}
</UnstyledButton>
<Popover position="bottom-end" withArrow shadow="md">
<Popover.Target>
<ActionIcon variant="subtle" size="sm" aria-label="Stat abbreviations and MMR info">
<ActionIcon variant="subtle" size="sm" aria-label={t`Stat abbreviations and MMR info`}>
<InfoIcon size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Box maw={280}>
<Text size="sm" fw={500} mb="xs">
Stat Abbreviations:
<Trans>Stat Abbreviations:</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>M:</strong> Matches
<Trans>• <strong>M:</strong> Matches</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>T:</strong> Tournaments
<Trans>• <strong>T:</strong> Tournaments</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>MMR:</strong> Matchmaking Rating
<Trans>• <strong>MMR:</strong> Matchmaking Rating</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>W:</strong> Wins
<Trans>• <strong>W:</strong> Wins</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>L:</strong> Losses
<Trans>• <strong>L:</strong> Losses</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>W%:</strong> Win Percentage
<Trans>• <strong>W%:</strong> Win Percentage</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>AWM:</strong> Average Win Margin
<Trans>• <strong>AWM:</strong> Average Win Margin</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>ALM:</strong> Average Loss Margin
<Trans>• <strong>ALM:</strong> Average Loss Margin</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>AC:</strong> Average Cups Per Match
<Trans>• <strong>AC:</strong> Average Cups Per Match</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>ACA:</strong> Average Cups Against
<Trans>• <strong>ACA:</strong> Average Cups Against</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>CF:</strong> Cups For
<Trans>• <strong>CF:</strong> Cups For</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>CA:</strong> Cups Against
<Trans>• <strong>CA:</strong> Cups Against</Trans>
</Text>
<Divider my="sm" />
<Text size="sm" fw={500} mb="xs">
MMR Calculation:
<Trans>MMR Calculation:</Trans>
</Text>
<Text size="xs" mb={2}>
• Win Rate (50%)
<Trans>• Win Rate (50%)</Trans>
</Text>
<Text size="xs" mb={2}>
• Average Cups/Match (25%)
<Trans>• Average Cups/Match (25%)</Trans>
</Text>
<Text size="xs" mb={2}>
• Average Win Margin (15%)
<Trans>• Average Win Margin (15%)</Trans>
</Text>
<Text size="xs" mb={2}>
• Match Volume Bonus (10%)
<Trans>• Match Volume Bonus (10%)</Trans>
</Text>
<Text size="xs" mt="xs" c="dimmed">
* Confidence penalty applied for players with &lt;15 matches
<Trans>* Confidence penalty applied for players with &lt;15 matches</Trans>
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating
<Trans>** Not an official rating</Trans>
</Text>
</Box>
</Popover.Dropdown>
@@ -470,7 +473,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
{filteredAndSortedStats.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No players found matching "{search}"
<Trans>No players found matching "{search}"</Trans>
</Text>
)}
</Stack>
@@ -8,6 +8,7 @@ import {
Divider,
UnstyledButton,
} from "@mantine/core";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { Player } from "../types";
import { usePlayersActivity } from "../queries";
@@ -16,18 +17,20 @@ interface PlayerActivityItemProps {
}
const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
const { t, i18n } = useLingui();
const playerName = player.first_name && player.last_name
? `${player.first_name} ${player.last_name}`
: player.first_name || player.last_name || "Unknown Player";
: player.first_name || player.last_name || t`Unknown Player`;
const formatDate = (dateStr?: string) => {
if (!dateStr) return "Never";
if (!dateStr) return t`Never`;
const date = new Date(dateStr);
return date.toLocaleString();
return date.toLocaleString(i18n.locale);
};
const getTimeSince = (dateStr?: string) => {
if (!dateStr) return "Never active";
if (!dateStr) return t`Never active`;
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -35,10 +38,10 @@ const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 30) return `${diffDays}d ago`;
if (diffMins < 1) return t`Just now`;
if (diffMins < 60) return t`${diffMins}m ago`;
if (diffHours < 24) return t`${diffHours}h ago`;
if (diffDays < 30) return t`${diffDays}d ago`;
return formatDate(dateStr);
};
@@ -94,7 +97,7 @@ export const PlayersActivityTable = () => {
<Stack gap="xs">
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
{players.length} players
<Plural value={players.length} one="# player" other="# players" />
</Text>
</Group>
@@ -109,7 +112,7 @@ export const PlayersActivityTable = () => {
{players.length === 0 && (
<Text ta="center" c="dimmed" py="xl">
No player activity found
<Trans>No player activity found</Trans>
</Text>
)}
</Stack>
@@ -3,6 +3,7 @@ import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
import NameUpdateForm from "./name-form";
import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet";
@@ -14,6 +15,7 @@ interface HeaderProps {
}
const Header = ({ player }: HeaderProps) => {
const { t } = useLingui();
const nameSheet = useSheet();
const h2hSheet = useSheet();
const { user: authUser } = useAuth();
@@ -80,12 +82,12 @@ const Header = ({ player }: HeaderProps) => {
</Flex>
</Stack>
<Sheet title='Update Name' {...nameSheet.props}>
<Sheet title={t`Update Name`} {...nameSheet.props}>
<NameUpdateForm player={player} toggle={nameSheet.toggle} />
</Sheet>
{!owner && authUser && (
<Sheet title="Head to Head" {...h2hSheet.props}>
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
<PlayerHeadToHeadSheet
player1Id={authUser.id}
player1Name={authUserName}
@@ -1,5 +1,6 @@
import { Box, Stack, Text, Divider, Group, Button, Anchor } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
@@ -22,11 +23,11 @@ const StatsWithFilter = ({ id }: { id: string }) => {
return (
<Stack>
<Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}>Statistics</Text>
<Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}>All</Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}>Mainline</Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}>Regional</Button>
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button>
</Group>
</Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -44,18 +45,20 @@ const StatsContent = ({ id, viewType }: { id: string; viewType: 'all' | 'mainlin
};
const Profile = ({ id }: ProfileProps) => {
const { t } = useLingui();
const { data: player } = usePlayer(id);
const { data: matches } = usePlayerMatches(id);
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <>
<Stack px="md">
<Group justify="space-between" align="center">
<Text size="md" fw={700}>Badges</Text>
<Text size="md" fw={700}><Trans>Badges</Trans></Text>
<Anchor component={Link} to="/badges" size="sm" fw={500}>
View all badges
<Trans>View all badges</Trans>
</Anchor>
</Group>
<Suspense fallback={<BadgeShowcaseSkeleton />}>
@@ -67,11 +70,13 @@ const Profile = ({ id }: ProfileProps) => {
</>,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchList matches={matches || []} />,
},
{
label: "Teams",
label: t`Teams`,
value: "teams",
content: <TeamList teams={player.teams || []} />,
},
];
@@ -1,6 +1,7 @@
import { updatePlayer } from "@/features/players/server";
import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { Trans, useLingui } from "@lingui/react/macro";
import { Player } from "../../types";
import Button from "@/components/button";
import { useOptimisticMutation } from "@/lib/tanstack-query/hooks";
@@ -12,6 +13,8 @@ interface NameUpdateFormProps {
}
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
const { t } = useLingui();
const form = useForm({
initialValues: {
first_name: player.first_name,
@@ -19,14 +22,14 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
},
validate: {
first_name: (value: string | undefined) => {
if (!value || value.length === 0) return "First name is required";
if (!value || value.length === 0) return t`First name is required`;
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
return "First name must be 2-20 characters long and contain only letters";
return t`First name must be 2-20 characters long and contain only letters`;
},
last_name: (value: string | undefined) => {
if (!value || value.length === 0) return "Last name is required";
if (!value || value.length === 0) return t`Last name is required`;
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
return "Last name must be 2-20 characters long and contain only letters";
return t`Last name must be 2-20 characters long and contain only letters`;
},
},
});
@@ -36,7 +39,7 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
await updatePlayer({ data }),
onSuccess: toggle,
onError: toggle,
successMessage: "Name updated successfully!",
successMessage: t`Name updated successfully!`,
optimisticUpdate: (oldData, variables) => {
if (!oldData) return oldData;
return {
@@ -61,13 +64,13 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="xs">
<TextInput label="First Name" {...form.getInputProps("first_name")} />
<TextInput label="Last Name" {...form.getInputProps("last_name")} />
<TextInput label={t`First Name`} {...form.getInputProps("first_name")} />
<TextInput label={t`Last Name`} {...form.getInputProps("last_name")} />
<Button loading={isPending} type="submit">
Save
<Trans>Save</Trans>
</Button>
<Button variant="subtle" color="red" onClick={toggle}>
Cancel
<Trans>Cancel</Trans>
</Button>
</Stack>
</form>
@@ -4,6 +4,7 @@ import TeamList from "@/features/teams/components/team-list";
import { StatsSkeleton } from "@/components/stats-overview";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import HeaderSkeleton from "./header-skeleton";
import { useLingui } from "@lingui/react/macro";
const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
@@ -58,17 +59,21 @@ const OverviewSkeleton = () => (
);
const ProfileSkeleton = () => {
const { t } = useLingui();
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <OverviewSkeleton />,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchListSkeleton />,
},
{
label: "Teams",
label: t`Teams`,
value: "teams",
content: <TeamList teams={[]} loading />,
},
];
+9 -2
View File
@@ -15,6 +15,10 @@ export const fetchMe = createServerFn()
.handler(async () =>
toServerResult(async () => {
const request = getRequest();
const { parseAcceptLanguage } = await import("@/lib/i18n");
const detectedLocale = parseAcceptLanguage(
request.headers.get("accept-language")
);
try {
const context = await getSessionContext(request);
@@ -22,13 +26,16 @@ export const fetchMe = createServerFn()
return {
user: context.player || undefined,
roles: context.roles,
metadata: context.metadata,
metadata: {
...context.metadata,
locale: context.metadata?.locale ?? detectedLocale,
},
phone: context.phone
};
} catch (error: any) {
if (isRedirect(error) || error instanceof Response) throw error;
return { user: undefined, roles: [], metadata: {}, phone: undefined };
return { user: undefined, roles: [], metadata: { locale: detectedLocale }, phone: undefined };
}
})
);
@@ -1,5 +1,6 @@
import React from "react";
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { TeamInfo } from "@/features/teams/types";
import TeamAvatar from "@/components/team-avatar";
import PlayerAvatar from "@/components/player-avatar";
@@ -40,10 +41,14 @@ export const MatchupSheet: React.FC<MatchupSheetProps> = ({
away,
isOpen,
}) => {
const { t } = useLingui();
if (!home && !away) {
return (
<Text size="sm" c="dimmed" ta="center" py="md">
Pick the earlier matches first — these teams aren't decided yet.
<Trans>
Pick the earlier matches first — these teams aren't decided yet.
</Trans>
</Text>
);
}
@@ -56,15 +61,15 @@ export const MatchupSheet: React.FC<MatchupSheetProps> = ({
<TeamRow team={home} />
) : (
<Text size="sm" c="dimmed">
Home team TBD — pick the earlier matches first
<Trans>Home team TBD — pick the earlier matches first</Trans>
</Text>
)}
<Divider label="vs" labelPosition="center" />
<Divider label={t`vs`} labelPosition="center" />
{away ? (
<TeamRow team={away} />
) : (
<Text size="sm" c="dimmed">
Away team TBD — pick the earlier matches first
<Trans>Away team TBD — pick the earlier matches first</Trans>
</Text>
)}
</Stack>
@@ -9,6 +9,7 @@ import {
} from "@mantine/core";
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Trans, useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Tournament } from "@/features/tournaments/types";
@@ -35,6 +36,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
tournament,
initialPicks,
}) => {
const { t, i18n } = useLingui();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const matchupSheet = useSheet();
@@ -176,7 +178,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
c="var(--mantine-primary-color-filled)"
style={{ letterSpacing: 0.5 }}
>
{getMatchLabel(matches, activeMatch)}
{getMatchLabel(i18n, matches, activeMatch)}
</Text>
</Group>
<WinnerSelector
@@ -195,7 +197,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
radius="md"
onClick={() => stepTo(-1)}
disabled={activeIndex <= 0}
aria-label="Previous match"
aria-label={t`Previous match`}
>
<CaretLeftIcon size={16} />
</ActionIcon>
@@ -205,7 +207,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
radius="md"
onClick={() => stepTo(1)}
disabled={activeIndex < 0 || activeIndex >= pickable.length - 1}
aria-label="Next match"
aria-label={t`Next match`}
>
<CaretRightIcon size={16} />
</ActionIcon>
@@ -215,7 +217,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
radius="md"
onClick={matchupSheet.open}
disabled={!activeResolved?.home && !activeResolved?.away}
aria-label="Matchup details"
aria-label={t`Matchup details`}
>
<InfoIcon size={16} />
</ActionIcon>
@@ -229,7 +231,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
loading={submit.isPending}
onClick={handleSubmit}
>
Submit
<Trans>Submit</Trans>
</Button>
</Group>
</Group>
@@ -238,7 +240,7 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
</Box>
<Sheet
title={activeMatch ? getMatchLabel(matches, activeMatch) : "Matchup"}
title={activeMatch ? getMatchLabel(i18n, matches, activeMatch) : t`Matchup`}
{...matchupSheet.props}
>
<MatchupSheet
@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import { CrownIcon, InfoIcon } from "@phosphor-icons/react";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { Tournament } from "@/features/tournaments/types";
import PlayerAvatar from "@/components/player-avatar";
@@ -28,6 +29,7 @@ interface PredictionLeaderboardProps {
export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
tournament,
}) => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: leaderboard } = usePredictionsLeaderboard(tournament.id);
@@ -60,9 +62,11 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
})
}
>
{myPrediction?.prediction
? "Edit Your Prediction"
: "Make Your Prediction"}
{myPrediction?.prediction ? (
<Trans>Edit Your Prediction</Trans>
) : (
<Trans>Make Your Prediction</Trans>
)}
</Button>
) : undefined;
@@ -76,11 +80,13 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
/>
<Stack align="center" gap={4}>
<Title order={3} c="dimmed" ta="center">
Predictions are open
<Trans>Predictions are open</Trans>
</Title>
<Text size="sm" c="dimmed" ta="center" maw={280}>
Other players' predictions are hidden until the tournament
starts.
<Trans>
Other players' predictions are hidden until the tournament
starts.
</Trans>
</Text>
</Stack>
{cta}
@@ -88,7 +94,11 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
{leaderboard.submitters.length > 0 && (
<>
<Text px="md" size="sm" fw={600} pb="xs">
{leaderboard.count} bracket{leaderboard.count === 1 ? "" : "s"} in
<Plural
value={leaderboard.count}
one="# bracket in"
other="# brackets in"
/>
</Text>
{leaderboard.submitters.map((player, index) => {
const name = `${player.first_name} ${player.last_name}`;
@@ -120,10 +130,10 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
/>
<Stack align="center" gap={4}>
<Title order={3} c="dimmed" ta="center">
No predictions
<Trans>No predictions</Trans>
</Title>
<Text size="sm" c="dimmed" ta="center" maw={280}>
Nobody made a prediction for this tournament.
<Trans>Nobody made a prediction for this tournament.</Trans>
</Text>
</Stack>
</Stack>
@@ -134,14 +144,14 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
<Stack gap={0}>
<Group px="md" justify="space-between" align="center" wrap="nowrap">
<Text size="lg" fw={600}>
Predictions
<Trans>Predictions</Trans>
</Text>
<Popover position="bottom-end" withArrow shadow="md">
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
aria-label="How prediction scoring works"
aria-label={t`How prediction scoring works`}
>
<InfoIcon size={14} />
</ActionIcon>
@@ -149,42 +159,50 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
<Popover.Dropdown>
<Box maw={280}>
<Text size="sm" fw={500} mb="xs">
Prediction Scoring:
<Trans>Prediction Scoring:</Trans>
</Text>
<Text size="xs" mb={2}>
• Each correct pick earns points, doubling every round
<Trans>
• Each correct pick earns points, doubling every round
</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>Winners bracket:</strong> 10, 20, 40, 80…
<Trans>
• <strong>Winners bracket:</strong> 10, 20, 40, 80…
</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>Losers bracket:</strong> 5, 10, 20, 40…
<Trans>
• <strong>Losers bracket:</strong> 5, 10, 20, 40…
</Trans>
</Text>
<Text size="xs" mb={2}>
• <strong>Bracket reset:</strong> only picked if your bracket
triggers it — worth double the Final
<Trans>
• <strong>Bracket reset:</strong> only picked if your
bracket triggers it — worth double the Final
</Trans>
</Text>
<Divider my="sm" />
<Text size="sm" fw={500} mb="xs">
Tiebreakers:
<Trans>Tiebreakers:</Trans>
</Text>
<Text size="xs" mb={2}>
1. Correct champion pick
<Trans>1. Correct champion pick</Trans>
</Text>
<Text size="xs" mb={2}>
2. Earlier submission
<Trans>2. Earlier submission</Trans>
</Text>
<Text size="xs" mt="xs" c="dimmed">
* PICKS shows correct picks / total picks made
<Trans>* PICKS shows correct picks / total picks made</Trans>
</Text>
</Box>
</Popover.Dropdown>
</Popover>
</Group>
<Text px="md" c="dimmed" size="xs" fw={500}>
Correct picks are worth more each round
<Trans>Correct picks are worth more each round</Trans>
</Text>
{leaderboard.entries.map((entry, index) => {
const name = `${entry.player.first_name} ${entry.player.last_name}`;
@@ -240,7 +258,7 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
<Group gap="md" wrap="nowrap">
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PTS
<Trans>PTS</Trans>
</Text>
<Text size="sm" fw={700}>
{entry.points}
@@ -248,7 +266,7 @@ export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
</Stack>
<Stack gap={0} ta="center">
<Text size="xs" c="dimmed" fw={700}>
PICKS
<Trans>PICKS</Trans>
</Text>
<Text size="xs" c="dimmed">
{entry.correct}/{entry.total}
@@ -1,4 +1,5 @@
import { Card, Flex, Text } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import React from "react";
import { MatchSlot, MatchSlotState } from "@/features/bracket/components/match-slot";
import { Match } from "@/features/matches/types";
@@ -104,7 +105,7 @@ export const PredictionMatchCard: React.FC<PredictionMatchCardProps> = ({
c="dimmed"
fw="bold"
>
* If necessary
<Trans>* If necessary</Trans>
</Text>
)}
</Card>
@@ -1,5 +1,6 @@
import React from "react";
import { Group, Text, UnstyledButton } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { CrownIcon } from "@phosphor-icons/react";
import { TeamInfo } from "@/features/teams/types";
import TeamAvatar from "@/components/team-avatar";
@@ -35,7 +36,7 @@ const TeamChip = ({
}}
>
<Text size="xs" c="dimmed">
TBD
<Trans>TBD</Trans>
</Text>
</Group>
);
@@ -102,7 +103,7 @@ export const WinnerSelector: React.FC<WinnerSelectorProps> = ({
onSelect={onSelect}
/>
<Text size="xs" c="dimmed" fw={700}>
vs
<Trans>vs</Trans>
</Text>
<TeamChip
team={away}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { useLingui } from "@lingui/react/macro";
import {
useServerMutation,
useServerSuspenseQuery,
@@ -43,10 +44,11 @@ export const usePlayerPrediction = (tournamentId: string, playerId: string) =>
export const useSubmitPrediction = (tournamentId: string) => {
const queryClient = useQueryClient();
const { t } = useLingui();
return useServerMutation({
mutationFn: submitPrediction,
successMessage: "Prediction saved!",
successMessage: t`Prediction saved!`,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: predictionKeys.tournament(tournamentId),
+13 -7
View File
@@ -1,3 +1,5 @@
import { msg } from "@lingui/core/macro";
import type { I18n } from "@lingui/core";
import { Match } from "@/features/matches/types";
import { Team, TeamInfo } from "@/features/teams/types";
import { Tournament } from "@/features/tournaments/types";
@@ -150,8 +152,12 @@ export const isPredictionComplete = (
);
};
export const getMatchLabel = (matches: Match[], match: Match): string => {
if (match.reset) return "Bracket Reset";
export const getMatchLabel = (
i18n: I18n,
matches: Match[],
match: Match
): string => {
if (match.reset) return i18n._(msg`Bracket Reset`);
const winners = matches.filter(
(m) => isBracketMatch(m) && !m.reset && !m.is_losers_bracket
@@ -161,24 +167,24 @@ export const getMatchLabel = (matches: Match[], match: Match): string => {
!highest || current.lid > highest.lid ? current : highest,
undefined
);
if (!grandFinal) return `Match ${match.order}`;
if (!grandFinal) return i18n._({ ...msg`Match {order}`, values: { order: match.order } });
const hasLosersBracket = matches.some(
(m) => isBracketMatch(m) && m.is_losers_bracket
);
if (match.lid === grandFinal.lid) return "Final";
if (match.lid === grandFinal.lid) return i18n._(msg`Final`);
if (
hasLosersBracket &&
!match.is_losers_bracket &&
grandFinal.home_from_lid === match.lid
) {
return "Winners Bracket Final";
return i18n._(msg`Winners Bracket Final`);
}
if (match.is_losers_bracket && grandFinal.away_from_lid === match.lid) {
return "Losers Bracket Final";
return i18n._(msg`Losers Bracket Final`);
}
return `Match ${match.order}`;
return i18n._({ ...msg`Match {order}`, values: { order: match.order } });
};
export type PickResult = "correct" | "incorrect" | "pending";
@@ -10,6 +10,7 @@ import {
import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useEffect, useCallback } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useLingui } from "@lingui/react/macro";
import Sheet from "@/components/sheet/sheet";
import PlayerList from "@/features/players/components/player-list";
import EmojiPicker from "./emoji-picker";
@@ -57,6 +58,7 @@ const EmojiBar = ({
onReactionPress,
}: EmojiBarProps) => {
const { user } = useAuth();
const { t } = useLingui();
const { data: reactions } = useMatchReactions(matchId);
const toggleReaction = useToggleMatchReaction(matchId, user);
const reduceMotion = useReducedMotion();
@@ -201,7 +203,7 @@ const EmojiBar = ({
/>
</Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Sheet title={t`Reactions`} opened={opened} onChange={() => close()}>
<Stack gap="md">
<ScrollArea w="100%" offsetScrollbars>
<Group gap="xs" wrap="nowrap" px="xs">
@@ -1,6 +1,8 @@
import { ActionIcon, Popover, SimpleGrid, UnstyledButton, Text } from "@mantine/core";
import { SmileyStickerIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
@@ -9,18 +11,18 @@ interface EmojiPickerProps {
}
const EMOJIS = [
{ emoji: "🫡", label: "salute" },
{ emoji: "😭", label: "crying" },
{ emoji: "🫦", label: "lip" },
{ emoji: "🏗️", label: "crane" },
{ emoji: "👀", label: "eyes" },
{ emoji: "😱", label: "scream" },
{ emoji: "🥹", label: "owo" },
{ emoji: "🤣", label: "rofl" },
{ emoji: "🤪", label: "crazy" },
{ emoji: "🤓", label: "nerd" },
{ emoji: "🥵", label: "hot" },
{ emoji: "🥶", label: "cold" },
{ emoji: "🫡", label: msg`salute` },
{ emoji: "😭", label: msg`crying` },
{ emoji: "🫦", label: msg`lip` },
{ emoji: "🏗️", label: msg`crane` },
{ emoji: "👀", label: msg`eyes` },
{ emoji: "😱", label: msg`scream` },
{ emoji: "🥹", label: msg`owo` },
{ emoji: "🤣", label: msg`rofl` },
{ emoji: "🤪", label: msg`crazy` },
{ emoji: "🤓", label: msg`nerd` },
{ emoji: "🥵", label: msg`hot` },
{ emoji: "🥶", label: msg`cold` },
];
const EmojiPicker = ({
@@ -29,6 +31,7 @@ const EmojiPicker = ({
userReactions = []
}: EmojiPickerProps) => {
const [opened, setOpened] = useState(false);
const { t } = useLingui();
const handleEmojiSelect = (emoji: string) => {
onSelect(emoji);
@@ -52,7 +55,7 @@ const EmojiPicker = ({
variant="subtle"
onClick={() => setOpened((o) => !o)}
disabled={disabled}
aria-label="Select emoji"
aria-label={t`Select emoji`}
>
<SmileyStickerIcon size={16} />
</ActionIcon>
@@ -88,7 +91,7 @@ const EmojiPicker = ({
},
},
}}
aria-label={label}
aria-label={t(label)}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
@@ -1,4 +1,5 @@
import { Box, ColorSwatch, Group, Text } from '@mantine/core';
import { Trans } from '@lingui/react/macro';
import { updateUserAccentColor } from '@/features/settings/server';
import { useAuth } from '@/contexts/auth-context';
@@ -43,7 +44,7 @@ const AccentColorPicker = () => {
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Accent Color</Text>
<Text fw={500} size='sm' mb='xs'><Trans>Accent Color</Trans></Text>
<Group gap='xs' w='100%' justify='space-between'>
{colors.map((color) => (
<ColorButton
@@ -1,5 +1,6 @@
import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core';
import { SunIcon, MoonIcon, Icon, MonitorIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro';
import { updateUserColorScheme } from '@/features/settings/server';
import { useAuth } from '@/contexts/auth-context';
@@ -16,7 +17,8 @@ const ColorSchemeLabel: React.FC<ColorSchemeLabelProps> = ({ colorScheme, Icon }
export function ColorSchemePicker() {
const { metadata, user, set } = useAuth()
const { t } = useLingui();
const handleClick = async (value: string) => {
if (user) {
await updateUserColorScheme({ data: value });
@@ -26,7 +28,7 @@ export function ColorSchemePicker() {
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Color Scheme</Text>
<Text fw={500} size='sm' mb='xs'><Trans>Color Scheme</Trans></Text>
<SegmentedControl
w='100%'
value={metadata.colorScheme}
@@ -34,15 +36,15 @@ export function ColorSchemePicker() {
data={[
{
value: 'dark',
label: <ColorSchemeLabel colorScheme='Dark' Icon={MoonIcon} />
label: <ColorSchemeLabel colorScheme={t`Dark`} Icon={MoonIcon} />
},
{
value: 'light',
label: <ColorSchemeLabel colorScheme='Light' Icon={SunIcon} />
label: <ColorSchemeLabel colorScheme={t`Light`} Icon={SunIcon} />
},
{
value: 'auto',
label: <ColorSchemeLabel colorScheme='System' Icon={MonitorIcon} />
label: <ColorSchemeLabel colorScheme={t`System`} Icon={MonitorIcon} />
},
]}
/>
@@ -0,0 +1,44 @@
import { Select, Text, Box } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { updateUserLocale } from "@/features/settings/server";
import { useAuth } from "@/contexts/auth-context";
import {
ensureMessages,
resolveLocale,
LOCALE_LABELS,
SUPPORTED_LOCALES,
type AppLocale,
} from "@/lib/i18n";
const LocalePicker = () => {
const { metadata, user, set } = useAuth();
const { t } = useLingui();
const handleChange = async (value: string | null) => {
if (!value || !user) return;
const locale = resolveLocale(value);
await ensureMessages(locale);
await updateUserLocale({ data: locale });
set({ metadata: { ...metadata, locale } });
};
return (
<Box>
<Text fw={500} size="sm" mb="xs">
<Trans>Language</Trans>
</Text>
<Select
aria-label={t`Language`}
data={SUPPORTED_LOCALES.map((locale: AppLocale) => ({
value: locale,
label: LOCALE_LABELS[locale],
}))}
value={resolveLocale(metadata.locale)}
onChange={handleChange}
allowDeselect={false}
/>
</Box>
);
};
export default LocalePicker;
@@ -1,10 +1,12 @@
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { useWebPush } from "@/hooks/use-web-push";
import toast from "@/lib/sonner";
export function NotificationsSection() {
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
useWebPush();
const { t } = useLingui();
if (!supported || !configured) {
return (
@@ -13,11 +15,11 @@ export function NotificationsSection() {
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Title order={3}>Notifications</Title>
<Title order={3}><Trans>Notifications</Trans></Title>
<Text size="sm" c="dimmed" mt="xs">
{supported
? "Notifications aren't available right now."
: "This device doesn't support push notifications. On iOS, install the app to your Home Screen first."}
? <Trans>Notifications aren't available right now.</Trans>
: <Trans>This device doesn't support push notifications. On iOS, install the app to your Home Screen first.</Trans>}
</Text>
</Box>
);
@@ -27,25 +29,25 @@ export function NotificationsSection() {
if (checked) {
const ok = await enable();
if (ok) {
toast.success("Notifications enabled on this device");
toast.success(t`Notifications enabled on this device`);
} else if (permission === "denied") {
toast.error(
"Notifications are blocked. Enable them in your browser settings."
t`Notifications are blocked. Enable them in your browser settings.`
);
} else {
toast.error("Couldn't enable notifications");
toast.error(t`Couldn't enable notifications`);
}
} else {
const ok = await disable();
if (ok) toast.success("Notifications disabled on this device");
else toast.error("Couldn't disable notifications");
if (ok) toast.success(t`Notifications disabled on this device`);
else toast.error(t`Couldn't disable notifications`);
}
};
const handleTest = async () => {
const ok = await sendTest();
if (ok) toast.success("Test notification sent");
else toast.error("Couldn't send test notification");
if (ok) toast.success(t`Test notification sent`);
else toast.error(t`Couldn't send test notification`);
};
return (
@@ -54,13 +56,13 @@ export function NotificationsSection() {
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Title order={3}>Notifications</Title>
<Title order={3}><Trans>Notifications</Trans></Title>
<Stack mt="xs" gap="sm">
<Switch
checked={subscribed}
onChange={(e) => handleToggle(e.currentTarget.checked)}
disabled={busy}
label="Enable notifications on this device"
label={t`Enable notifications on this device`}
/>
{subscribed && (
<Group>
@@ -70,7 +72,7 @@ export function NotificationsSection() {
onClick={handleTest}
loading={busy}
>
Send test notification
<Trans>Send test notification</Trans>
</Button>
</Group>
)}
+26
View File
@@ -25,6 +25,32 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
};
});
export const updateUserLocale = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { SUPPORTED_LOCALES } = await import("@/lib/i18n");
if (!(SUPPORTED_LOCALES as readonly string[]).includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
locale: data,
});
return {
metadata: {
...metadata,
locale: data,
},
};
});
export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
@@ -10,6 +10,7 @@ import {
FloppyDiskIcon,
ClockCounterClockwiseIcon,
} from '@phosphor-icons/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useSpotify } from '@/lib/spotify/hooks';
import { useAuth } from '@/contexts/auth-context';
import SpotifySheet from './spotify-sheet';
@@ -18,6 +19,7 @@ const SpotifyControlsBar = () => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
const { t } = useLingui();
const spotify = useSpotify();
@@ -47,7 +49,7 @@ const SpotifyControlsBar = () => {
<Group justify="center" gap="sm">
<SpotifyLogoIcon size={24} color="var(--mantine-color-green-6)" />
<Text size="sm" c="dimmed">
Connect Spotify
<Trans>Connect Spotify</Trans>
</Text>
<ActionIcon
variant="light"
@@ -100,7 +102,7 @@ const SpotifyControlsBar = () => {
)}
<Group gap="xs">
<Tooltip label="Previous track">
<Tooltip label={t`Previous track`}>
<ActionIcon
variant="light"
size="lg"
@@ -112,7 +114,7 @@ const SpotifyControlsBar = () => {
</ActionIcon>
</Tooltip>
<Tooltip label={isPlaying ? 'Pause' : 'Play'}>
<Tooltip label={isPlaying ? t`Pause` : t`Play`}>
<ActionIcon
variant="filled"
color="green"
@@ -125,7 +127,7 @@ const SpotifyControlsBar = () => {
</ActionIcon>
</Tooltip>
<Tooltip label="Next track">
<Tooltip label={t`Next track`}>
<ActionIcon
variant="light"
size="lg"
@@ -139,7 +141,7 @@ const SpotifyControlsBar = () => {
</Group>
<Group gap="xs">
<Tooltip label={'Capture current state'}>
<Tooltip label={t`Capture current state`}>
<ActionIcon
variant="light"
color={capturedState ? 'blue' : 'gray'}
@@ -152,7 +154,7 @@ const SpotifyControlsBar = () => {
</ActionIcon>
</Tooltip>
<Tooltip label={capturedState ? 'Restore captured state' : 'No state captured'}>
<Tooltip label={capturedState ? t`Restore captured state` : t`No state captured`}>
<ActionIcon
variant="light"
color={capturedState ? 'blue' : 'gray'}
@@ -170,12 +172,12 @@ const SpotifyControlsBar = () => {
{activeDevice && (
<Box>
<Text size="xs" c="dimmed">
Playing on {activeDevice.name}
<Trans>Playing on {activeDevice.name}</Trans>
</Text>
</Box>
)}
<Tooltip label="Spotify settings">
<Tooltip label={t`Spotify settings`}>
<ActionIcon
variant="light"
color="green"
@@ -199,7 +201,7 @@ const SpotifyControlsBar = () => {
{isAuthenticated && !hasActiveDevice && !isLoading && (
<Group justify="center" mt="xs">
<Text size="xs" c="orange">
No active device. Please select a device in settings.
<Trans>No active device. Please select a device in settings.</Trans>
</Text>
</Group>
)}
@@ -15,6 +15,7 @@ import {
SignOutIcon,
ArrowsClockwiseIcon
} from '@phosphor-icons/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useSpotify } from '@/lib/spotify/hooks';
import { useAuth } from '@/contexts/auth-context';
import Sheet from '@/components/sheet/sheet';
@@ -28,6 +29,7 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const spotify = useSpotify();
const { t } = useLingui();
if (!isAdmin || !spotify) return null;
const {
@@ -63,14 +65,14 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
<Sheet
opened={opened}
onChange={handleChange}
title="Spotify Controls"
title={t`Spotify Controls`}
>
<Stack gap="lg">
{!isAuthenticated ? (
<>
<Box>
<Text size="sm" c="dimmed" mb="md">
Connect your Spotify account
<Trans>Connect your Spotify account</Trans>
</Text>
</Box>
@@ -82,7 +84,7 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
loading={isLoading}
fullWidth
>
Connect with Spotify
<Trans>Connect with Spotify</Trans>
</Button>
</>
) : (
@@ -91,7 +93,7 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
{currentTrack && (
<>
<Box>
<Title order={5} mb="xs">Now Playing</Title>
<Title order={5} mb="xs"><Trans>Now Playing</Trans></Title>
<Group gap="md">
{currentTrack.album.images[2] && (
<img
@@ -122,7 +124,7 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
<Title order={5}>
<Group gap="xs">
<DevicesIcon size={20} />
Select Device
<Trans>Select Device</Trans>
</Group>
</Title>
<ActionIcon
@@ -140,25 +142,25 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
value={activeDevice?.id || ''}
onChange={(event) => handleDeviceChange(event.currentTarget.value)}
data={[
{ value: '', label: 'Select a device...' },
{ value: '', label: t`Select a device...` },
...devices.map(device => ({
value: device.id,
label: `${device.name} ${device.is_active ? '(Active)' : ''} - ${device.type}`,
label: `${device.name} ${device.is_active ? t`(Active)` : ''} - ${device.type}`,
})),
]}
disabled={isLoading}
/>
) : (
<Text size="sm" c="dimmed">
No devices found.
<Trans>No devices found.</Trans>
</Text>
)}
{activeDevice && (
<Text size="xs" c="dimmed" mt="xs">
Active device: {activeDevice.name}
{activeDevice.volume_percent !== null &&
` (Volume: ${activeDevice.volume_percent}%)`
<Trans>Active device: {activeDevice.name}</Trans>
{activeDevice.volume_percent !== null &&
t` (Volume: ${activeDevice.volume_percent}%)`
}
</Text>
)}
@@ -183,7 +185,7 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
onClick={logout}
fullWidth
>
Disconnect Spotify
<Trans>Disconnect Spotify</Trans>
</Button>
</>
)}
@@ -12,6 +12,7 @@ import {
MagnifyingGlassIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Tournament } from "@/features/tournaments/types";
import TeamList from "./team-list";
import Sheet from "@/components/sheet/sheet";
@@ -27,10 +28,11 @@ interface TeamEditSheetProps {
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
const { t } = useLingui();
return (
<Sheet
title={team ? `Edit ${team.name}` : "Edit Team"}
title={team ? t`Edit ${team.name}` : t`Edit Team`}
opened={isOpen}
onChange={onClose}
>
@@ -56,6 +58,7 @@ interface ManageTeamsProps {
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
const [search, setSearch] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const { t } = useLingui();
const {
isOpen: editTeamOpened,
@@ -109,10 +112,10 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<UsersIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Teams Enrolled
<Trans>No Teams Enrolled</Trans>
</Title>
<Text c="dimmed" ta="center">
This tournament has no enrolled teams yet.
<Trans>This tournament has no enrolled teams yet.</Trans>
</Text>
</Stack>
</Container>
@@ -124,7 +127,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<Container size="100%" px={0}>
<Stack gap="xs">
<TextInput
placeholder="Search teams by name or player..."
placeholder={t`Search teams by name or player...`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
@@ -134,7 +137,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<Box px="md">
<Text size="xs" c="dimmed">
{filteredTeams.length} of {teams.length} teams
<Trans>{filteredTeams.length} of {teams.length} teams</Trans>
</Text>
</Box>
@@ -145,7 +148,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
{filteredTeams.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No teams found matching "{search}"
<Trans>No teams found matching "{search}"</Trans>
</Text>
)}
</Stack>
@@ -5,6 +5,7 @@ import {
Box,
Title
} from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { useTeam } from "../queries";
import TeamAvatar from "@/components/team-avatar";
@@ -19,7 +20,7 @@ const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
<Trans>Failed to load team</Trans>
</Text>
</Paper>
);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Divider,
Title
} from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { useTeam } from "../queries";
import TeamAvatar from "@/components/team-avatar";
import SongSummary from "./team-form/song-summary";
@@ -22,7 +23,7 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
<Trans>Failed to load team</Trans>
</Text>
</Paper>
);
@@ -1,5 +1,6 @@
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
import React, { useState, useCallback, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
const presetColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
@@ -16,8 +17,10 @@ interface TeamColorPickerProps {
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
value,
onChange,
label = "Select Color"
label
}) => {
const { t } = useLingui();
const displayLabel = label ?? t`Select Color`;
const [customHex, setCustomHex] = useState(value || '');
const isValidHex = useMemo(() => {
@@ -41,7 +44,7 @@ const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
return (
<Stack gap="md" p="md" w="100%">
<Text fw={500}>{label}</Text>
<Text fw={500}>{displayLabel}</Text>
<ColorPicker
value={value || '#000000'}
@@ -57,11 +60,11 @@ const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
<Group gap="xs" align="flex-end">
<TextInput
style={{ flex: 1 }}
label="Custom Hex Code"
label={t`Custom Hex Code`}
placeholder="#FF0000"
value={customHex}
onChange={handleHexInputChange}
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
error={customHex && !isValidHex ? t`Invalid hex color format` : undefined}
w="100%"
/>
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
interface DurationPickerProps {
songDurationMs: number;
@@ -16,8 +17,9 @@ interface IncrementButtonsProps {
}
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
const { t } = useLingui();
const increments = [1, 5, 30, 60];
const labels = ["1s", "5s", "30s", "1m"];
const labels = [t`1s`, t`5s`, t`30s`, t`1m`];
return (
<Group gap={3} wrap="nowrap" flex={1}>
@@ -51,6 +53,7 @@ const DurationPicker = ({
onChange,
disabled = false,
}: DurationPickerProps) => {
const { t } = useLingui();
const songDurationSeconds = Math.floor(songDurationMs / 1000);
const formatTime = useCallback((seconds: number) => {
@@ -91,16 +94,16 @@ const DurationPicker = ({
const getValidationMessage = useCallback((start: number, end: number) => {
const duration = end - start;
if (start < 0 || end > songDurationSeconds) {
return "Segment must be within song duration";
return t`Segment must be within song duration`;
}
if (duration < 10) {
return "Segment must be at least 10 seconds";
return t`Segment must be at least 10 seconds`;
}
if (duration > 15) {
return "Segment must be no more than 15 seconds";
return t`Segment must be no more than 15 seconds`;
}
return null;
}, [songDurationSeconds]);
}, [songDurationSeconds, t]);
const updateTimes = useCallback((newStart: number, newEnd: number) => {
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
@@ -166,14 +169,14 @@ const DurationPicker = ({
return (
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
Walkout Segment ({segmentDuration}s)
<Trans>Walkout Segment ({segmentDuration}s)</Trans>
</Text>
<Stack gap="sm">
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
Start
<Trans>Start</Trans>
</Text>
<TextInput
value={startInputValue}
@@ -209,7 +212,7 @@ const DurationPicker = ({
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
End
<Trans>End</Trans>
</Text>
<TextInput
value={endInputValue}
@@ -14,6 +14,7 @@ import { TeamInput } from "../../types";
import { teamKeys } from "../../queries";
import SongPicker from "./song-picker";
import PlayersPicker from "./players-picker";
import { useLingui } from "@lingui/react/macro";
interface TeamFormProps {
close: () => void;
@@ -31,6 +32,7 @@ const TeamForm = ({
onSubmit
}: TeamFormProps) => {
const isEditMode = !!teamId;
const { t } = useLingui();
const config: UseFormInput<TeamInput> = {
initialValues: {
@@ -49,16 +51,16 @@ const TeamForm = ({
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
name: isNotEmpty(t`Name is required`),
players: (value: string[]) =>
value.length > 1 && value[1] !== ""
? undefined
: "Players are required",
song_name: isNotEmpty("Song is required"),
: t`Players are required`,
song_name: isNotEmpty(t`Song is required`),
song_start: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song start time is required";
return t`Song start time is required`;
}
}
return undefined;
@@ -66,15 +68,15 @@ const TeamForm = ({
song_end: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song end time is required";
return t`Song end time is required`;
}
if (values.song_start !== undefined && value !== undefined) {
const duration = value - values.song_start;
if (duration < 10) {
return "Song segment must be at least 10 seconds";
return t`Song segment must be at least 10 seconds`;
}
if (duration > 15) {
return "Song segment must be no more than 15 seconds";
return t`Song segment must be no more than 15 seconds`;
}
}
}
@@ -100,8 +102,8 @@ const TeamForm = ({
const mutation = isEditMode ? updateTeam : createTeam;
const errorMessage = isEditMode
? "Failed to update team"
: "Failed to create team";
? t`Failed to update team`
: t`Failed to create team`;
mutation(teamData, {
onSuccess: async (team: any) => {
@@ -143,7 +145,7 @@ const TeamForm = ({
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload logo");
throw new Error(error.error || t`Failed to upload logo`);
}
const result = await response.json();
@@ -160,8 +162,8 @@ const TeamForm = ({
);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Team updated but logo upload failed: ${error.message}`
: `Team created but logo upload failed: ${error.message}`;
? t`Team updated but logo upload failed: ${error.message}`
: t`Team created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Team logo upload error", error);
}
@@ -182,20 +184,20 @@ const TeamForm = ({
},
});
},
[isEditMode, createTeam, updateTeam, queryClient]
[isEditMode, createTeam, updateTeam, queryClient, t]
);
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Team" : "Create Team"}
cancelText="Cancel"
submitText={isEditMode ? t`Update Team` : t`Create Team`}
cancelText={t`Cancel`}
loading={isPending}
>
<Stack>
<TextInput
label="Name"
label={t`Name`}
withAsterisk
disabled={isEditMode}
key={form.key("name")}
@@ -205,7 +207,7 @@ const TeamForm = ({
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
label={isEditMode ? t`Change Logo` : t`Logo`}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps("logo")}
/>
@@ -3,6 +3,7 @@ import { useAuth } from "@/contexts/auth-context";
import { useUnenrolledPlayers } from "@/features/players/queries";
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLingui } from "@lingui/react/macro";
interface PlayersPickerProps {
tournamentId: string;
@@ -13,6 +14,7 @@ interface PlayersPickerProps {
}
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
const { t } = useLingui();
const { roles, user } = useAuth();
const isAdmin = roles.includes("Admin");
@@ -40,7 +42,7 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
return (
<Stack>
<Autocomplete
label="Player 1"
label={t`Player 1`}
data={options}
disabled
value={playerOne}
@@ -48,7 +50,7 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
comboboxProps={{ withinPortal: false }}
/>
<Autocomplete
label="Player 2"
label={t`Player 2`}
data={options}
disabled={disabled}
value={playerTwo}
@@ -8,6 +8,7 @@ import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNoteIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
interface Song {
song_id: string;
@@ -26,6 +27,7 @@ interface SongPickerProps {
}
const SongPicker = ({ form, error }: SongPickerProps) => {
const { t } = useLingui();
const currentSong = useMemo((): Song | undefined => {
const values = form.getValues();
if (values.song_id && values.song_name) {
@@ -63,9 +65,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
error={error}
Component={SongPickerComponent}
componentProps={{}}
title={"Select Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
title={t`Select Song`}
label={t`Walkout Song`}
placeholder={t`Select your walkout song`}
/>
);
};
@@ -76,6 +78,7 @@ interface SongPickerComponentProps {
}
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
const { t } = useLingui();
const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
@@ -106,7 +109,7 @@ const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps
return (
<Stack styles={{ root: { width: '100%' }}}>
<Text size="lg" fw={500}>Search for a Song</Text>
<Text size="lg" fw={500}><Trans>Search for a Song</Trans></Text>
<SongSearch onChange={handleSongSelect} />
@@ -122,10 +125,10 @@ const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps
</Avatar>
<div>
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
{song?.song_name || "No song selected"}
{song?.song_name || t`No song selected`}
</Text>
<Text size="xs" c="dimmed">
{song?.song_artist || "Select a song to see details"}
{song?.song_artist || t`Select a song to see details`}
</Text>
<Text size="xs" c="dimmed">
{song?.song_album || ""}
@@ -1,13 +1,16 @@
import { Text, Group, Avatar, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { useLingui } from "@lingui/react/macro";
interface SongSearchProps {
onChange: (track: SpotifyTrack) => void;
placeholder?: string;
}
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const SongSearch = ({ onChange, placeholder }: SongSearchProps) => {
const { t } = useLingui();
const resolvedPlaceholder = placeholder ?? t`Search for songs...`;
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
try {
// Use a default search term when query is empty to show popular tracks
@@ -72,7 +75,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
/>
)
};
@@ -1,5 +1,6 @@
import { Group, Avatar, Text, Box } from "@mantine/core";
import { MusicNoteIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
interface Song {
song_id: string;
@@ -27,7 +28,7 @@ const SongSummary = ({ song }: SongSummaryProps) => {
return (
<Group gap="xs" c="dimmed">
<MusicNoteIcon size={16} />
<Text size="sm" c="dimmed">No song selected</Text>
<Text size="sm" c="dimmed"><Trans>No song selected</Trans></Text>
</Group>
);
}
@@ -7,28 +7,31 @@ import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list";
import { MatchListSkeleton } from "./skeleton";
import { Trans, useLingui } from "@lingui/react/macro";
interface ProfileProps {
id: string;
}
const TeamProfile = ({ id }: ProfileProps) => {
const { t } = useLingui();
const { data: team } = useTeam(id);
const { data: matches, isLoading: matchesLoading } = useTeamMatches(id);
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
if (!team) return <Text p="md">Team not found</Text>;
if (!team) return <Text p="md"><Trans>Team not found</Trans></Text>;
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <>
<Stack px="md">
<Text size="md" fw={700}>Players</Text>
<Text size="md" fw={700}><Trans>Players</Trans></Text>
<PlayerList players={team.players} />
</Stack>
<Divider my="md" />
<Stack>
<Text px="md" size="md" fw={700}>Statistics</Text>
<Text px="md" size="md" fw={700}><Trans>Statistics</Trans></Text>
{statsLoading ? (
<StatsSkeleton />
) : (
@@ -38,7 +41,8 @@ const TeamProfile = ({ id }: ProfileProps) => {
</>,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: matchesLoading ? (
<MatchListSkeleton />
) : (
@@ -46,7 +50,8 @@ const TeamProfile = ({ id }: ProfileProps) => {
),
},
{
label: "Tournaments",
label: t`Tournaments`,
value: "tournaments",
content: (
<>
<TournamentList tournaments={team.tournaments || []} />
@@ -3,6 +3,7 @@ import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import { StatsSkeleton } from "@/components/stats-overview";
import HeaderSkeleton from "./header-skeleton";
import { useLingui } from "@lingui/react/macro";
const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
@@ -63,17 +64,21 @@ export const OverviewSkeleton = () => (
);
const ProfileSkeleton = () => {
const { t } = useLingui();
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <OverviewSkeleton />,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchListSkeleton />,
},
{
label: "Tournaments",
label: t`Tournaments`,
value: "tournaments",
content: <TournamentList tournaments={[]} loading />,
},
];
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { listPlayers } from '@/features/players/server';
import { useServerSuspenseQuery } from '@/lib/tanstack-query/hooks';
@@ -15,6 +16,7 @@ export const playerQueries = {
};
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const { t } = useLingui();
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
const availablePlayers = useMemo(() => {
@@ -30,9 +32,9 @@ export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const playerOptions = useMemo(() =>
availablePlayers.map(player => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
label: `${player.first_name} ${player.last_name}`.trim() || t`Unnamed Player`
})),
[availablePlayers]
[availablePlayers, t]
);
return {
+4 -2
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
import { createTeam } from "@/features/teams/server";
import { TeamInput } from "@/features/teams/types";
import { logger } from "@/lib/logger";
@@ -6,13 +7,14 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateTeam = () => {
const navigate = useNavigate();
const { t } = useLingui();
return useServerMutation({
mutationFn: (data: TeamInput) => createTeam({ data }),
onMutate: (data) => {
logger.info('Creating team', data);
},
successMessage: 'Team created successfully!',
successMessage: t`Team created successfully!`,
});
};
+5 -2
View File
@@ -1,16 +1,19 @@
import { useLingui } from "@lingui/react/macro";
import { updateTeam } from "@/features/teams/server";
import { TeamUpdateInput } from "@/features/teams/types";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { logger } from "@/lib/logger";
const useUpdateTeam = (teamId: string) => {
const { t } = useLingui();
return useServerMutation({
mutationFn: (data: TeamUpdateInput) =>
mutationFn: (data: TeamUpdateInput) =>
updateTeam({ data: { id: teamId, updates: data } }),
onMutate: (data) => {
logger.info('Updating team', { teamId, updates: Object.keys(data) });
},
successMessage: 'Team updated successfully!',
successMessage: t`Team updated successfully!`,
});
};
@@ -7,6 +7,7 @@ import {
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { useCallback, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useFreeAgents } from "../queries";
import PlayerAvatar from "@/components/player-avatar";
import { PlayerInfo, Player } from "@/features/players/types";
@@ -51,6 +52,7 @@ const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
});
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
const { t } = useLingui();
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
const { data: allPlayers = [] } = usePlayers();
@@ -112,10 +114,10 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
Add Player
<Trans>Add Player</Trans>
</Text>
<Typeahead
placeholder="Search for players to enroll..."
placeholder={t`Search for players to enroll...`}
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
@@ -127,10 +129,10 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
Enrolled Players
<Trans>Enrolled Players</Trans>
</Text>
<Text size="xs" c="dimmed">
{enrolledPlayers.length} players
<Plural value={enrolledPlayers.length} one="# player" other="# players" />
</Text>
</Group>
@@ -140,7 +142,7 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
</Group>
) : !hasEnrolledPlayers ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No players enrolled yet
<Trans>No players enrolled yet</Trans>
</Text>
) : (
<Stack gap="xs" w="100%">
@@ -8,6 +8,7 @@ import {
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
@@ -65,6 +66,7 @@ const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps
});
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { t } = useLingui();
const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
@@ -131,10 +133,10 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
Add Team
<Trans>Add Team</Trans>
</Text>
<Typeahead
placeholder="Search for teams to enroll..."
placeholder={t`Search for teams to enroll...`}
onSelect={handleEnrollTeam}
searchFn={searchTeams}
renderOption={renderTeamOption}
@@ -146,10 +148,10 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
Enrolled Teams
<Trans>Enrolled Teams</Trans>
</Text>
<Text size="xs" c="dimmed">
{enrolledTeams.length} teams
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
</Text>
</Group>
@@ -159,7 +161,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
</Group>
) : !hasEnrolledTeams ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No teams enrolled yet
<Trans>No teams enrolled yet</Trans>
</Text>
) : (
<Stack gap="xs" w="100%">
@@ -6,6 +6,7 @@ import { useState, useCallback } from "react";
import { useTournament } from "../queries";
import useUpdateTournament from "../hooks/use-update-tournament";
import { RichTextEditor } from "@/components/rich-text-editor";
import { Trans } from "@lingui/react/macro";
interface EditRulesProps {
tournamentId: string;
@@ -35,8 +36,8 @@ const EditRules = ({ tournamentId, onClose }: EditRulesProps) => {
return (
<Stack gap="xs" w="100%">
<RichTextEditor value={value || ""} onChange={setValue} />
<Button onClick={() => handleSubmit(value)}>Submit</Button>
<Button variant="subtle" color="red" onClick={onClose}>Cancel</Button>
<Button onClick={() => handleSubmit(value)}><Trans>Submit</Trans></Button>
<Button variant="subtle" color="red" onClick={onClose}><Trans>Cancel</Trans></Button>
</Stack>
);
};
@@ -11,6 +11,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { MatchForm } from "@/features/bracket/components/match-form";
import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import { useLingui } from "@lingui/react/macro";
interface GroupMatchCardProps {
match: Match;
@@ -19,6 +20,7 @@ interface GroupMatchCardProps {
}
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, nextUpMatchId }) => {
const { t } = useLingui();
const queryClient = useQueryClient();
const editSheet = useSheet();
@@ -32,7 +34,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
const start = useServerMutation({
mutationFn: startMatch,
successMessage: "Match started!",
successMessage: t`Match started!`,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
@@ -113,7 +115,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
style={{ flex: 1, minWidth: 0 }}
lineClamp={1}
>
{match.home?.name || "TBD"}
{match.home?.name || t`TBD`}
</Text>
</Group>
{isEnded && match.home_cups !== undefined && (
@@ -143,7 +145,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
style={{ flex: 1, minWidth: 0 }}
lineClamp={1}
>
{match.away?.name || "TBD"}
{match.away?.name || t`TBD`}
</Text>
</Group>
{isEnded && match.away_cups !== undefined && (
@@ -163,7 +165,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
{showStartButton && (
<ActionIcon
color="green"
aria-label="Start match"
aria-label={t`Start match`}
onClick={handleStartMatch}
loading={start.isPending}
size="md"
@@ -182,7 +184,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
{showEditButton && (
<ActionIcon
color="blue"
aria-label="Edit match score"
aria-label={t`Edit match score`}
onClick={editSheet.open}
size="md"
h="100%"
@@ -199,7 +201,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, ne
</Flex>
{showControls && (
<Sheet title="End Match" opened={editSheet.isOpen} onChange={editSheet.toggle}>
<Sheet title={t`End Match`} opened={editSheet.isOpen} onChange={editSheet.toggle}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
@@ -1,6 +1,7 @@
import { Stack, Text, Card, Group, Box } from "@mantine/core";
import { TeamInfo } from "@/features/teams/types";
import TeamAvatar from "@/components/team-avatar";
import { Trans, Plural } from "@lingui/react/macro";
interface GroupAssignment {
groupIndex: number;
@@ -20,10 +21,10 @@ const GroupPreview: React.FC<GroupPreviewProps> = ({ groups }) => {
<Stack gap="sm">
<Group gap="xs" align="center">
<Text fw={600} size="sm">
Group {group.groupName}
<Trans>Group {group.groupName}</Trans>
</Text>
<Text size="xs" c="dimmed">
({group.teams.length} teams)
(<Plural value={group.teams.length} one="# team" other="# teams" />)
</Text>
</Group>
@@ -10,6 +10,7 @@ import { populateKnockoutBracket } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
import { computeGroupStageOrder } from "../utils/match-queue";
import { Trans, useLingui } from "@lingui/react/macro";
interface GroupStageViewProps {
groups: Group[];
@@ -43,12 +44,13 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
groupConfig,
nextUpMatchId,
}) => {
const { t } = useLingui();
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
const populateKnockoutMutation = useServerMutation({
mutationFn: populateKnockoutBracket,
successMessage: "Knockout bracket populated successfully!",
successMessage: t`Knockout bracket populated successfully!`,
onSuccess: () => {
if (tournamentId) {
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
@@ -289,7 +291,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
return (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
No groups have been created yet
<Trans>No groups have been created yet</Trans>
</Text>
</Box>
);
@@ -301,15 +303,15 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<Box p="md">
<Stack gap="md">
{showGenerateKnockoutButton && (
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
<Alert color="blue" title={t`Group Stage Complete`} icon={<CaretCircleUpIcon size={20} />}>
<Stack gap="xs">
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
<Text size="sm"><Trans>All group matches are finished! Populate the knockout bracket to advance qualified teams.</Trans></Text>
<Button
onClick={handlePopulateKnockout}
loading={populateKnockoutMutation.isPending}
size="sm"
>
Populate Knockout Bracket
<Trans>Populate Knockout Bracket</Trans>
</Button>
</Stack>
</Alert>
@@ -330,7 +332,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
}}
>
<MantineGroup gap="xs" justify="center" wrap="nowrap">
<Text fw={600} size="sm">Group {group.name}</Text>
<Text fw={600} size="sm"><Trans>Group {group.name}</Trans></Text>
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
({completedMatches}/{totalMatches})
</Text>
@@ -358,12 +360,12 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
onClick={() => toggleTeams(group.id)}
>
<Text fw={600} size="sm">
Standings ({standings.length})
<Trans>Standings ({standings.length})</Trans>
</Text>
<ActionIcon
variant="subtle"
size="sm"
aria-label={expandedTeams[group.id] ? "Collapse standings" : "Expand standings"}
aria-label={expandedTeams[group.id] ? t`Collapse standings` : t`Expand standings`}
>
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
</ActionIcon>
@@ -402,12 +404,12 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<MantineGroup gap="xs" wrap="nowrap">
{isWildcard && (
<Badge size="xs" color="yellow" variant="light">
WC
<Trans>WC</Trans>
</Badge>
)}
{isQualified && (
<Badge size="xs" variant="light">
Q
<Trans>Q</Trans>
</Badge>
)}
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
@@ -431,7 +433,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
})
) : (
<Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned
<Trans>No teams assigned</Trans>
</Text>
)}
</Stack>
@@ -441,7 +443,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
{groupMatches.length === 0 ? (
<Card withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">
No matches scheduled
<Trans>No matches scheduled</Trans>
</Text>
</Card>
) : (
@@ -17,12 +17,14 @@ import EditEnrolledPlayers from "./edit-enrolled-players";
import ListLink from "@/components/list-link";
import React from "react";
import EditRules from "./edit-rules";
import { useLingui } from "@lingui/react/macro";
interface ManageTournamentProps {
tournamentId: string;
}
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
const { t } = useLingui();
const { data: tournament } = useTournament(tournamentId);
if (!tournament) throw new Error("Tournament not found.");
@@ -48,43 +50,43 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
<>
<List p="0">
<ListButton
label="Edit Tournament"
label={t`Edit Tournament`}
Icon={HardDrivesIcon}
onClick={openEditTournament}
/>
<ListButton
label="Edit Rules"
label={t`Edit Rules`}
Icon={PencilLineIcon}
onClick={openEditRules}
/>
<ListButton
label="Edit Enrollments"
label={t`Edit Enrollments`}
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
{tournament.regional && (
<ListLink
label="Assign Partners"
label={t`Assign Partners`}
Icon={ShuffleIcon}
to={`/admin/tournaments/${tournamentId}/assign-partners`}
/>
)}
{!tournament.regional && (
<ListLink
label="Manage Team Songs/Logos"
label={t`Manage Team Songs/Logos`}
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
)}
<ListLink
label="Run Tournament"
label={t`Run Tournament`}
Icon={TreeStructureIcon}
to={`/admin/tournaments/run/${tournamentId}`}
/>
</List>
<Sheet
title="Edit Tournament"
title={t`Edit Tournament`}
opened={editTournamentOpened}
onChange={closeEditTournament}
>
@@ -104,7 +106,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
</Sheet>
<Sheet
title="Edit Rules"
title={t`Edit Rules`}
opened={editRulesOpened}
onChange={closeEditRules}
>
@@ -112,7 +114,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
</Sheet>
<Sheet
title={tournament.regional === true ? "Manage Enrollments" : "Edit Enrolled Teams"}
title={tournament.regional === true ? t`Manage Enrollments` : t`Edit Enrolled Teams`}
opened={editTeamsOpened}
onChange={closeEditTeams}
>
@@ -2,6 +2,9 @@ import { Stack, Group, Text, ThemeIcon } from "@mantine/core";
import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
import { TeamInfo } from "@/features/teams/types";
import { Tournament } from "../types";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import type { MessageDescriptor } from "@lingui/core";
interface PodiumProps {
tournament: Tournament;
@@ -40,7 +43,7 @@ const tierConfig: Record<
PodiumTier,
{
color: string;
label: string;
label: MessageDescriptor;
labelColor: string;
icon: React.ReactNode;
iconSize: "xl" | "lg";
@@ -50,7 +53,7 @@ const tierConfig: Record<
> = {
first: {
color: "yellow",
label: "Champions",
label: msg`Champions`,
labelColor:
"light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4))",
icon: <CrownIcon size={24} />,
@@ -60,7 +63,7 @@ const tierConfig: Record<
},
second: {
color: "gray",
label: "2nd place",
label: msg`2nd place`,
labelColor:
"light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
icon: <MedalIcon size={20} />,
@@ -70,7 +73,7 @@ const tierConfig: Record<
},
third: {
color: "orange",
label: "3rd place",
label: msg`3rd place`,
labelColor:
"light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4))",
icon: <MedalIcon size={18} />,
@@ -81,6 +84,7 @@ const tierConfig: Record<
};
const PodiumRow = ({ team, tier }: { team: TeamInfo; tier: PodiumTier }) => {
const { t } = useLingui();
const config = tierConfig[tier];
return (
@@ -121,7 +125,7 @@ const PodiumRow = ({ team, tier }: { team: TeamInfo; tier: PodiumTier }) => {
mt={4}
mr={4}
>
{config.label}
{t(config.label)}
</Text>
</Group>
);
@@ -6,31 +6,36 @@ import SwipeableTabs from "@/components/swipeable-tabs";
import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import { TournamentStats } from "../tournament-stats";
import { useLingui } from "@lingui/react/macro";
interface ProfileProps {
id: string;
}
const Profile = ({ id }: ProfileProps) => {
const { t } = useLingui();
const { data: tournament } = useTournament(id);
if (!tournament) return null;
const tabs = useMemo(() => [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <TournamentStats tournament={tournament} />
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
},
{
label: "Teams",
label: t`Teams`,
value: "teams",
content: <>
<TeamList teams={tournament.teams || []} isRegional={tournament.regional} />
</>
}
], [tournament]);
], [tournament, t]);
return <>
<Header tournament={tournament} />
@@ -2,6 +2,7 @@ import { Box, Divider, Group, Paper, Skeleton, Stack } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import TeamList from "@/features/teams/components/team-list";
import HeaderSkeleton from "./header-skeleton";
import { useLingui } from "@lingui/react/macro";
const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
@@ -72,17 +73,21 @@ const OverviewSkeleton = () => (
);
const ProfileSkeleton = () => {
const { t } = useLingui();
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <OverviewSkeleton />,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchListSkeleton />,
},
{
label: "Teams",
label: t`Teams`,
value: "teams",
content: <TeamList teams={[]} loading />,
},
];
@@ -19,6 +19,7 @@ import { BracketData } from "@/features/bracket/types";
import BracketView from "@/features/bracket/components/bracket-view";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
interface SeedTournamentProps {
tournamentId: string;
@@ -31,6 +32,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
teams,
isRegional,
}) => {
const { t } = useLingui();
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
const { data: bracketPreview } = useBracketPreview(teams.length);
const queryClient = useQueryClient();
@@ -57,7 +59,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
const generateBracket = useServerMutation({
mutationFn: generateTournamentBracket,
successMessage: "Tournament bracket generated successfully!",
successMessage: t`Tournament bracket generated successfully!`,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(tournamentId),
@@ -108,10 +110,14 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
<Group gap="xs" p="md" pb="sm" align="center">
<Text fw={600} size="lg">
Team Seeding
<Trans>Team Seeding</Trans>
</Text>
<Text size="sm" c="dimmed" ml="auto">
{orderedTeams.length} teams
<Plural
value={orderedTeams.length}
one="# team"
other="# teams"
/>
</Text>
</Group>
@@ -122,7 +128,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
loading={generateBracket.isPending}
disabled={orderedTeams.length === 0}
>
Confirm Seeding
<Trans>Confirm Seeding</Trans>
</Button>
<DragDropContext onDragEnd={handleDragEnd}>
@@ -201,7 +207,7 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
loading={generateBracket.isPending}
disabled={orderedTeams.length === 0}
>
Confirm Seeding
<Trans>Confirm Seeding</Trans>
</Button>
</Stack>
@@ -12,6 +12,8 @@ import {
Box,
} from "@mantine/core";
import { InfoIcon } from "@phosphor-icons/react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { generateGroupStage } from "../server";
import { TeamInfo } from "@/features/teams/types";
@@ -21,6 +23,10 @@ import {
getGroupName,
GroupConfigOption,
} from "../utils/group-config";
import {
formatGroupRankLabel,
formatWildcardLabel,
} from "@/features/bracket/utils/group-label";
import GroupPreview from "./group-preview";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
@@ -39,17 +45,18 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
teams,
}) => {
const queryClient = useQueryClient();
const { t, i18n } = useLingui();
const [selectedConfigIndex, setSelectedConfigIndex] = useState<string>("0");
const [seed, setSeed] = useState<number>(Date.now());
const configurations = useMemo(() => {
try {
return calculateGroupConfigurations(teams.length);
return calculateGroupConfigurations(i18n, teams.length);
} catch (error) {
return [];
}
}, [teams.length]);
}, [teams.length, i18n]);
const selectedConfig: GroupConfigOption | null = useMemo(() => {
const index = parseInt(selectedConfigIndex);
@@ -105,20 +112,20 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
const group1 = i % selectedConfig.num_groups;
const rankIndex1 = Math.floor(i / selectedConfig.num_groups);
const rank1 = rankIndex1 + 1;
const groupName1 = groupNames[group1] || `Group ${group1 + 1}`;
const rankSuffix1 = rank1 === 1 ? '1st' : rank1 === 2 ? '2nd' : rank1 === 3 ? '3rd' : `${rank1}th`;
seedLabels[seedIndex++] = `${groupName1} ${rankSuffix1}`;
const groupName1 = groupNames[group1] ||
i18n._({ ...msg`Group {n}`, values: { n: group1 + 1 } });
seedLabels[seedIndex++] = formatGroupRankLabel(i18n, groupName1, rank1);
const group2 = (i + 1) % selectedConfig.num_groups;
const rankIndex2 = selectedConfig.advance_per_group - 1 - rankIndex1;
const rank2 = rankIndex2 + 1;
const groupName2 = groupNames[group2] || `Group ${group2 + 1}`;
const rankSuffix2 = rank2 === 1 ? '1st' : rank2 === 2 ? '2nd' : rank2 === 3 ? '3rd' : `${rank2}th`;
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
const groupName2 = groupNames[group2] ||
i18n._({ ...msg`Group {n}`, values: { n: group2 + 1 } });
seedLabels[seedIndex++] = formatGroupRankLabel(i18n, groupName2, rank2);
}
for (let i = 0; i < wildcardsNeeded; i++) {
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
seedLabels[seedIndex++] = formatWildcardLabel(i18n, i + 1);
}
const ordersMap: Record<number, number> = {};
@@ -154,11 +161,11 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
);
return { matches: placeholderMatches, orders: ordersMap };
}, [knockoutTeamCount, selectedConfig]);
}, [knockoutTeamCount, selectedConfig, i18n]);
const generateGroups = useServerMutation({
mutationFn: generateGroupStage,
successMessage: "Group stage generated successfully!",
successMessage: t`Group stage generated successfully!`,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(tournamentId),
@@ -195,8 +202,10 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
if (configurations.length === 0) {
return (
<Alert color="red" title="Cannot create groups" icon={<InfoIcon />}>
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
<Alert color="red" title={t`Cannot create groups`} icon={<InfoIcon />}>
<Trans>
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
</Trans>
</Alert>
);
}
@@ -209,17 +218,17 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
<Group gap="xs" p="md" pb="sm" align="center">
<Text fw={600} size="lg">
Group Stage Setup
<Trans>Group Stage Setup</Trans>
</Text>
<Text size="sm" c="dimmed" ml="auto">
{teams.length} teams
<Plural value={teams.length} one="# team" other="# teams" />
</Text>
</Group>
<Stack gap="md" p="md" pt={0}>
<Stack gap="xs">
<Text size="sm" fw={500}>
Group Configuration
<Trans>Group Configuration</Trans>
</Text>
<Select
value={selectedConfigIndex}
@@ -237,18 +246,27 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
{selectedConfig && (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{selectedConfig.total_group_matches} total group stage matches
<Plural
value={selectedConfig.total_group_matches}
one="# total group stage match"
other="# total group stage matches"
/>
</Text>
{selectedConfig.wildcards_needed > 0 && (
<Text size="xs" c="yellow">
⚠ {selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
⚠{" "}
<Plural
value={selectedConfig.wildcards_needed}
one="# wildcard spot needed for knockout bracket"
other="# wildcard spots needed for knockout bracket"
/>
</Text>
)}
</Stack>
)}
<Button size="sm" variant="light" onClick={handleShuffle}>
Shuffle Groups
<Trans>Shuffle Groups</Trans>
</Button>
<Button
@@ -257,7 +275,7 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
loading={generateGroups.isPending}
disabled={!selectedConfig}
>
Confirm Setup
<Trans>Confirm Setup</Trans>
</Button>
</Stack>
</Stack>
@@ -273,11 +291,32 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
<Box>
<Divider mb="lg" />
<Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
<Trans>
Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
</Trans>
</Title>
<Text size="sm" c="dimmed" ta="center" mb="lg">
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
{selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
<Trans>
Top{" "}
<Plural
value={selectedConfig?.advance_per_group ?? 0}
one="# team"
other="# teams"
/>{" "}
from each group will advance
{selectedConfig?.wildcards_needed ? (
<>
{" + "}
<Plural
value={selectedConfig.wildcards_needed}
one="# wildcard"
other="# wildcards"
/>
</>
) : (
""
)}
</Trans>
</Text>
<Box
style={{
@@ -2,9 +2,11 @@ import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
import { Tournament } from "../../types";
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import GlitchAvatar from "@/components/glitch-avatar";
const Header = ({ tournament }: { tournament: Tournament }) => {
const { i18n } = useLingui();
const tournamentStart = useMemo(
() => new Date(tournament.start_time),
[tournament.start_time]
@@ -46,16 +48,18 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed" lineClamp={1}>
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
<Trans>
{tournamentStart.toLocaleDateString(i18n.locale, {
weekday: "short",
month: "short",
day: "numeric",
})}{" "}
at{" "}
{tournamentStart.toLocaleTimeString(i18n.locale, {
hour: "2-digit",
minute: "2-digit",
})}
</Trans>
</Text>
</Group>
</Stack>
@@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
import { ClockIcon } from "@phosphor-icons/react";
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
import { Trans, useLingui } from "@lingui/react/macro";
import { predictionQueries } from "@/features/predictions/queries";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import { Match } from "@/features/matches/types";
@@ -22,6 +23,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { user, roles } = useAuth();
const { t } = useLingui();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
@@ -109,21 +111,21 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
{isTournamentOver && (
<Box>
<SectionHeading label="Final Results" />
<SectionHeading label={t`Final Results`} />
<Podium tournament={tournament} />
</Box>
)}
{liveMatches.length > 0 && (
<Box>
<SectionHeading label="Live Matches" live count={liveMatches.length} />
<SectionHeading label={t`Live Matches`} live count={liveMatches.length} />
<MatchCarousel matches={liveMatches} />
</Box>
)}
{upcomingMatches.length > 0 && (
<Box>
<SectionHeading label="Upcoming" count={upcomingMatches.length} />
<SectionHeading label={t`Upcoming`} count={upcomingMatches.length} />
{/* SIDE BETS SLOT — see docs/side-bets.md */}
<MatchCarousel matches={upcomingMatches} />
</Box>
@@ -137,7 +139,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
<Stack align="center" gap="md">
<ClockIcon size={48} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500} c="dimmed">
No active matches
<Trans>No active matches</Trans>
</Text>
</Stack>
</Center>
@@ -17,6 +17,8 @@ import {
UsersIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Trans, useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import TeamList from "@/features/teams/components/team-list";
@@ -73,13 +75,14 @@ const NavGrid = ({
const navigate = useNavigate();
const teamsSheet = useSheet();
const rulesSheet = useSheet();
const { t, i18n } = useLingui();
return (
<Box px="md">
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
{isAdmin && (
<NavTile
label="Manage"
label={t`Manage`}
Icon={UsersIcon}
onClick={() =>
navigate({ to: `/admin/tournaments/${tournament.id}` })
@@ -88,7 +91,7 @@ const NavGrid = ({
)}
{hasGroupStage && (
<NavTile
label="Groups"
label={t`Groups`}
Icon={ListDashes}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/groups` })
@@ -96,7 +99,7 @@ const NavGrid = ({
/>
)}
<NavTile
label="Bracket"
label={t`Bracket`}
Icon={TreeStructureIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/bracket` })
@@ -104,7 +107,7 @@ const NavGrid = ({
/>
{isPredictable && !predictionsLocked && (
<NavTile
label={hasSubmitted ? "Edit Prediction" : "Make Prediction"}
label={hasSubmitted ? t`Edit Prediction` : t`Make Prediction`}
Icon={WizardOrbIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
@@ -113,7 +116,7 @@ const NavGrid = ({
)}
{isPredictable && (predictionsLocked || hasSubmitted) && (
<NavTile
label="Predictions"
label={t`Predictions`}
Icon={WizardOrbIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/predictions` })
@@ -121,23 +124,28 @@ const NavGrid = ({
/>
)}
<NavTile
label={`Teams${
tournament.teams ? ` (${tournament.teams.length})` : ""
}`}
label={
tournament.teams
? i18n._({
...msg`{count, plural, one {Teams (#)} other {Teams (#)}}`,
values: { count: tournament.teams.length },
})
: t`Teams`
}
Icon={UsersIcon}
onClick={teamsSheet.open}
/>
<NavTile label="Rules" Icon={ListIcon} onClick={rulesSheet.open} />
<NavTile label={t`Rules`} Icon={ListIcon} onClick={rulesSheet.open} />
</SimpleGrid>
<Sheet title="Enrolled Teams" {...teamsSheet.props}>
<Sheet title={t`Enrolled Teams`} {...teamsSheet.props}>
<TeamList
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
</Sheet>
<Sheet title="Tournament Rules" {...rulesSheet.props}>
<Sheet title={t`Tournament Rules`} {...rulesSheet.props}>
<Stack gap="xs">
<Suspense
fallback={
@@ -154,7 +162,7 @@ const NavGrid = ({
<RulesContent content={tournament.rules || ""} />
</Suspense>
<Button variant="subtle" c="red" onClick={rulesSheet.close}>
Close
<Trans>Close</Trans>
</Button>
</Stack>
</Sheet>
@@ -1,6 +1,7 @@
import { Anchor, Box, Group, Stack } from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Trans, useLingui } from "@lingui/react/macro";
import MatchCard from "@/features/matches/components/match-card";
import { Match } from "@/features/matches/types";
import SectionHeading from "./section-heading";
@@ -20,6 +21,7 @@ interface RecentResultsProps {
*/
const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
const navigate = useNavigate();
const { t } = useLingui();
if (matches.length === 0) return null;
@@ -29,7 +31,7 @@ const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) =
return (
<Box>
<SectionHeading
label="Recently finished"
label={t`Recently finished`}
action={
hasMore ? (
<Anchor
@@ -42,7 +44,7 @@ const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) =
}
>
<Group gap={2} align="center" wrap="nowrap">
View all results
<Trans>View all results</Trans>
<CaretRightIcon size={12} weight="bold" />
</Group>
</Anchor>
@@ -1,4 +1,5 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { Match } from "@/features/matches/types";
import { MatchReport } from "@/features/bracket/components/match-report";
import TeamAvatar from "@/components/team-avatar";
@@ -9,8 +10,8 @@ interface ScoreReportCtaProps {
matches: Match[];
}
const teamName = (t: Match["home"]): string =>
t && typeof t !== "string" ? t.name : "TBD";
const teamName = (team: Match["home"], fallback: string): string =>
team && typeof team !== "string" ? team.name : fallback;
/**
* Top-of-home call-to-action for a participant whose match is live. Composes
@@ -19,12 +20,14 @@ const teamName = (t: Match["home"]): string =>
* user isn't playing in a started match.
*/
const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
const { t } = useLingui();
if (matches.length === 0) return null;
return (
<Box>
<SectionHeading
label="Your match is live"
label={t`Your match is live`}
live
count={matches.length}
/>
@@ -43,11 +46,11 @@ const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
/>
)}
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{teamName(match.home)}
{teamName(match.home, t`TBD`)}
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} style={{ flexShrink: 0 }}>
vs
<Trans>vs</Trans>
</Text>
<Group
gap={8}
@@ -62,7 +65,7 @@ const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
ta="right"
style={{ minWidth: 0 }}
>
{teamName(match.away)}
{teamName(match.away, t`TBD`)}
</Text>
{match.away && typeof match.away !== "string" && (
<TeamAvatar
@@ -1,4 +1,5 @@
import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { PlayerInfo } from "@/features/players/types";
import PlayerAvatar from "@/components/player-avatar";
@@ -19,7 +20,7 @@ const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignmen
<Card key={index} withBorder radius="md" p="md">
<Group gap="md" align="center" wrap="nowrap">
<Text size="sm" fw={600} c="dimmed" w={40}>
#{index + 1}
<Trans>#{index + 1}</Trans>
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
@@ -30,7 +31,7 @@ const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignmen
</Group>
<Text size="lg" c="dimmed">
&
<Trans>&</Trans>
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
@@ -3,12 +3,14 @@ import { useTournaments } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import { Button, Stack } from "@mantine/core";
import { PlusIcon, TrophyIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { TournamentCard } from "./tournament-card";
import EmptyState from "@/components/empty-state";
const TournamentCardList = () => {
const { t } = useLingui();
const { data: tournaments } = useTournaments();
const { roles } = useAuth();
const sheet = useSheet();
@@ -21,9 +23,9 @@ const TournamentCardList = () => {
variant="subtle"
onClick={sheet.open}
>
Create Tournament
<Trans>Create Tournament</Trans>
</Button>
<Sheet {...sheet.props} title="Create Tournament">
<Sheet {...sheet.props} title={t`Create Tournament`}>
<TournamentForm close={sheet.close} />
</Sheet>
</>
@@ -35,8 +37,8 @@ const TournamentCardList = () => {
) : (
<EmptyState
icon={<TrophyIcon size={32} />}
title="No Tournaments Yet"
description="The cups are racked and waiting. Check back soon."
title={t`No Tournaments Yet`}
description={t`The cups are racked and waiting. Check back soon.`}
/>
)}
</Stack>
@@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useCallback } from "react";
import { DateTimePicker } from "@/components/date-time-picker";
import { useLingui } from "@lingui/react/macro";
interface TournamentFormProps {
close: () => void;
@@ -25,6 +26,7 @@ const TournamentForm = ({
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
const { t, i18n } = useLingui();
const config: UseFormInput<TournamentInput> = {
initialValues: {
@@ -39,10 +41,10 @@ const TournamentForm = ({
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
location: isNotEmpty("Location is required"),
start_time: isNotEmpty("Start time is required"),
enroll_time: isNotEmpty("Enrollment time is required"),
name: isNotEmpty(t`Name is required`),
location: isNotEmpty(t`Location is required`),
start_time: isNotEmpty(t`Start time is required`),
enroll_time: isNotEmpty(t`Enrollment time is required`),
},
};
@@ -62,11 +64,11 @@ const TournamentForm = ({
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode
? "Tournament updated successfully!"
: "Tournament created successfully!";
? t`Tournament updated successfully!`
: t`Tournament created successfully!`;
const errorMessage = isEditMode
? "Failed to update tournament"
: "Failed to create tournament";
? t`Failed to update tournament`
: t`Failed to create tournament`;
mutation(tournamentData, {
onSuccess: async (tournament) => {
@@ -83,7 +85,7 @@ const TournamentForm = ({
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload logo");
throw new Error(error.error || t`Failed to upload logo`);
}
const result = await response.json();
@@ -100,8 +102,8 @@ const TournamentForm = ({
toast.success(successMessage);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Tournament updated but logo upload failed: ${error.message}`
: `Tournament created but logo upload failed: ${error.message}`;
? t`Tournament updated but logo upload failed: ${error.message}`
: t`Tournament created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Tournament logo upload error", error);
}
@@ -126,33 +128,33 @@ const TournamentForm = ({
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
cancelText="Cancel"
submitText={isEditMode ? t`Update Tournament` : t`Create Tournament`}
cancelText={t`Cancel`}
loading={isPending}
>
<Stack>
<TextInput
label="Name"
label={t`Name`}
withAsterisk
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
label={t`Location`}
withAsterisk
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
label={t`Description`}
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<Checkbox
label="Regional Tournament"
label={t`Regional Tournament`}
key={form.key("regional")}
{...form.getInputProps("regional", { type: "checkbox" })}
/>
@@ -160,7 +162,7 @@ const TournamentForm = ({
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
label={isEditMode ? t`Change Logo` : t`Logo`}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps("logo")}
/>
@@ -169,12 +171,12 @@ const TournamentForm = ({
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
title={t`Select Start Date`}
label={t`Start Date`}
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
!date ? t`Select a time` :
new Date(date).toLocaleDateString(i18n.locale, {
weekday: "short",
year: "numeric",
month: "short",
@@ -190,12 +192,12 @@ const TournamentForm = ({
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
title={t`Select Enrollment Due Date`}
label={t`Enrollment Due`}
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
!date ? t`Select a time` :
new Date(date).toLocaleDateString(i18n.locale, {
weekday: "short",
year: "numeric",
month: "short",
@@ -212,11 +214,11 @@ const TournamentForm = ({
key={form.key("end_time")}
{...form.getInputProps("end_time")}
Component={DateTimePicker}
title="Select End Date"
label="End Date (Optional)"
title={t`Select End Date`}
label={t`End Date (Optional)`}
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
!date ? t`Select a time` :
new Date(date).toLocaleDateString(i18n.locale, {
weekday: "short",
year: "numeric",
month: "short",
@@ -5,6 +5,7 @@ import { TournamentInfo } from "../types";
import { Fragment, useCallback } from "react";
import React from "react";
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
interface TournamentListProps {
tournaments: TournamentInfo[];
@@ -16,6 +17,7 @@ interface TournamentListItemProps {
}
const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps) => {
const { i18n } = useLingui();
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
return (
@@ -41,7 +43,7 @@ const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps)
<CalendarIcon size={10} />
</ThemeIcon>
<Text size="xs" c="dimmed">
{startDate.toLocaleDateString(undefined, {
{startDate.toLocaleDateString(i18n.locale, {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -90,7 +92,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
<TrophyIcon size={32} />
</ThemeIcon>
<Text c="dimmed" size="lg">
No tournaments found
<Trans>No tournaments found</Trans>
</Text>
</Box>
);
@@ -18,12 +18,14 @@ import TeamAvatar from "@/components/team-avatar";
import ListLink from "@/components/list-link";
import { isTournamentPredictable } from "@/features/predictions/utils";
import { Podium } from "./podium";
import { Trans, useLingui } from "@lingui/react/macro";
interface TournamentStatsProps {
tournament: Tournament;
}
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
const { t } = useLingui();
const matches = tournament.matches || [];
const nonByeMatches = useMemo(() =>
@@ -62,7 +64,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
No stats available yet
<Trans>No stats available yet</Trans>
</Text>
</Center>
</Box>
@@ -71,8 +73,8 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
return (
<Stack gap={0}>
<Text px="md" size="lg" fw={600}>Results</Text>
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
<Text px="md" size="lg" fw={600}><Trans>Results</Trans></Text>
<Text px="md" c="dimmed" size="xs" fw={500}><Trans>Sorted by win percentage</Trans></Text>
{teamStatsWithCalculations.map((stat, index) => {
const team = tournament.teams?.find(t => t.id === stat.team_id);
@@ -107,7 +109,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
<Group gap="md" ta="center">
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W
<Trans>W</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.wins}
@@ -115,7 +117,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
L
<Trans>L</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.losses}
@@ -123,7 +125,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W%
<Trans>W%</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.winPercentage.toFixed(1)}%
@@ -131,7 +133,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
AVG
<Trans>AVG</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.avgCupsPerMatch.toFixed(1)}
@@ -139,7 +141,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CF
<Trans>CF</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_made}
@@ -147,7 +149,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CA
<Trans>CA</Trans>
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_against}
@@ -170,27 +172,27 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
<Container size="100%" px={0}>
<Stack gap="md">
{tournament.regional && !hasGroupStage && (
<Alert px="md" variant="light" title="Regional Tournament" icon={<InfoIcon size={16} />}>
Earlier regional formats aren't supported in the app and order of matches or displayed winners may be unreliable.
<Alert px="md" variant="light" title={t`Regional Tournament`} icon={<InfoIcon size={16} />}>
<Trans>Earlier regional formats aren't supported in the app and order of matches or displayed winners may be unreliable.</Trans>
</Alert>
)}
{!tournament.regional && <Podium tournament={tournament} />}
<Stack gap={0}>
{hasGroupStage && (
<ListLink
label={`View Groups`}
label={t`View Groups`}
to={`/tournaments/${tournament.id}/groups`}
Icon={ListDashes}
/>
)}
<ListLink
label={`View Bracket`}
label={t`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
{isTournamentPredictable(tournament) && (
<ListLink
label={`View Predictions`}
label={t`View Predictions`}
to={`/tournaments/${tournament.id}/predictions`}
Icon={WizardOrbIcon}
/>
@@ -4,10 +4,12 @@ import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
import { Trans, useLingui } from "@lingui/react/macro";
const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user, phone } = useAuth();
const { t } = useLingui();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
const handleEnroll = () => {
@@ -21,32 +23,32 @@ const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, is
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
{isRegional ? "Enroll" : "Enroll As Free Agent"}
{isRegional ? <Trans>Enroll</Trans> : <Trans>Enroll As Free Agent</Trans>}
</Button>
<Sheet title={isRegional ? "Enrollment" : "Free Agent Enrollment"} opened={isOpen} onChange={toggle}>
<Sheet title={isRegional ? t`Enrollment` : t`Free Agent Enrollment`} opened={isOpen} onChange={toggle}>
<Stack gap="xs">
{isRegional ? (
<>
<Text size="md">
Enroll in this regional tournament to be assigned a random partner.
<Trans>Enroll in this regional tournament to be assigned a random partner.</Trans>
</Text>
</>
) : (
<>
<Text size="md">
Enrolling as a free agent adds you to a pool of players looking for teammates.
<Trans>Enrolling as a free agent adds you to a pool of players looking for teammates.</Trans>
</Text>
<Text size="sm" c='dimmed'>
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
<Trans>Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.</Trans>
</Text>
<Text size="xs" c="dimmed">
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
<Trans>Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.</Trans>
</Text>
</>
)}
<Button onClick={handleEnroll} loading={isEnrolling}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
<Button onClick={handleEnroll} loading={isEnrolling}><Trans>Confirm</Trans></Button>
<Button variant="subtle" color="red" onClick={toggle}><Trans>Cancel</Trans></Button>
</Stack>
</Sheet>
</>
@@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { Loader } from "@mantine/core";
import useEnrollTeam from "@/features/tournaments/hooks/use-enroll-team";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import { Trans, useLingui } from "@lingui/react/macro";
interface EnrollTeamProps {
tournamentId: string;
@@ -20,6 +21,7 @@ interface EnrollTeamProps {
const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { t } = useLingui();
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
const [selectedTeamId, setSelectedTeamId] = useState<string>();
@@ -88,10 +90,10 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
return (
<>
<Button size="sm" onClick={open}>
Enroll Your Team
<Trans>Enroll Your Team</Trans>
</Button>
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
<Sheet title={showTeamForm ? t`Team Details` : t`Enroll Team`} opened={isOpen} onChange={toggle}>
{ isEnrolling && <Loader size="lg" /> }
{showTeamForm && !isEnrolling ? (
<TeamForm
@@ -108,7 +110,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
onSelect={handleSelect}
/>
<Button mt='sm' variant="subtle" color='red' size="sm" onClick={toggle}>
Cancel
<Trans>Cancel</Trans>
</Button>
</>
)}
@@ -2,6 +2,7 @@ import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core
import { PlusIcon } from '@phosphor-icons/react';
import React, { useMemo } from 'react';
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
import { Trans, useLingui } from '@lingui/react/macro';
interface TeamSelectionViewProps {
options: ComboboxItem[];
@@ -13,6 +14,7 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
onSelect
}) => {
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
const { t } = useLingui();
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
const filtered = options.filter(option =>
@@ -52,14 +54,14 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
variant="light"
fullWidth
>
Create New Team
<Trans>Create New Team</Trans>
</Button>
<Divider my="sm" label="or" />
<Divider my="sm" label={t`or`} />
<Stack gap="sm">
<Typeahead
placeholder="Select one of your existing teams"
placeholder={t`Select one of your existing teams`}
onSelect={handleTeamSelect}
searchFn={searchTeams}
renderOption={renderTeamOption}
@@ -72,7 +74,7 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
disabled={!selectedTeam}
fullWidth
>
Enroll Selected Team
<Trans>Enroll Selected Team</Trans>
</Button>
</Stack>
</Stack>
@@ -1,5 +1,6 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
@@ -12,12 +13,13 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const { user } = useAuth();
const { t } = useLingui();
const copyToClipboard = async (phone: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(phone);
toast.success("Phone number copied!");
toast.success(t`Phone number copied!`);
return;
}
@@ -32,13 +34,13 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
document.body.removeChild(textArea);
if (successful) {
toast.success("Phone number copied!");
toast.success(t`Phone number copied!`);
} else {
throw new Error("Copy command failed");
}
} catch (err) {
console.error("Failed to copy:", err);
toast.error("Failed to copy");
toast.error(t`Failed to copy`);
}
};
@@ -56,7 +58,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
<Group gap={4} align="center">
<CheckCircleIcon size={14} weight="fill" color="var(--mantine-color-green-6)" />
<Text size="xs" c="green" fw={500}>
Enrolled
<Trans>Enrolled</Trans>
</Text>
</Group>
</Box>
@@ -65,7 +67,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
</Card>
<Text size="xs" c="dimmed" ta="center">
Partners will be randomly assigned when enrollment closes
<Trans>Partners will be randomly assigned when enrollment closes</Trans>
</Text>
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
@@ -76,18 +78,18 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
return (
<Stack gap="sm">
<Text size="sm" fw={600} c="green">
✓ Enrolled as Free Agent
<Trans>✓ Enrolled as Free Agent</Trans>
</Text>
<Text size="xs" c="dimmed">
Other players looking for teammates:
<Trans>Other players looking for teammates:</Trans>
</Text>
{freeAgents.length > 1 ? (
<Stack gap="xs">
<Group gap="xs" align="center">
<Text size="xs" fw={500} c="dimmed">
Free Agents
<Trans>Free Agents</Trans>
</Text>
<Badge variant="light" size="xs" color="blue">
{freeAgents.length}
@@ -107,7 +109,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
<ActionIcon
variant="subtle"
size="sm"
aria-label="Copy phone number"
aria-label={t`Copy phone number`}
onClick={() => copyToClipboard(agent.phone!)}
style={{ cursor: 'pointer' }}
>
@@ -129,7 +131,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
</Stack>
) : (
<Text size="xs" c="dimmed" py="sm">
You're the first free agent!
<Trans>You're the first free agent!</Trans>
</Text>
)}
@@ -1,6 +1,8 @@
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { UserListIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { useMemo } from "react";
import { useFreeAgents } from "../../queries";
import { Text } from "@mantine/core";
@@ -16,6 +18,7 @@ const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ t
const { data: freeAgents } = useFreeAgents(tournamentId);
const count = useMemo(() => freeAgents.length, [freeAgents]);
const { open, isOpen, toggle } = useSheet();
const { t, i18n } = useLingui();
const players = useMemo(() =>
freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
@@ -24,12 +27,19 @@ const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ t
return (
<>
<NavTile label={`Players (${count})`} Icon={UserListIcon} onClick={open} />
<NavTile
label={i18n._({
...msg`{count, plural, one {Players (#)} other {Players (#)}}`,
values: { count },
})}
Icon={UserListIcon}
onClick={open}
/>
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
<Sheet title={t`Enrolled Players`} opened={isOpen} onChange={toggle}>
{count === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No players enrolled yet
<Trans>No players enrolled yet</Trans>
</Text>
) : (
<PlayerList players={players} />

Some files were not shown because too many files have changed in this diff Show More