i18n
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
<Trans>Progress </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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
};
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 <15 matches
|
||||
<Trans>* Confidence penalty applied for players with <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 />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!`,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+9
-6
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+6
-4
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+13
-3
@@ -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
Reference in New Issue
Block a user