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

This commit is contained in:
2026-08-08 15:29:40 -07:00
parent 22c282d8fa
commit 0a7b5fa00f
178 changed files with 13099 additions and 1285 deletions
+15 -13
View File
@@ -23,25 +23,27 @@ import { useEffect } from 'react'
import toast from '@/lib/sonner'
import { logger } from '@/lib/logger'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter()
const navigate = useNavigate()
const { t } = useLingui()
const isRoot = useMatch({
strict: false,
select: (state) => state.id === rootRouteId,
})
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
const errorMessage = error?.message || 'Unknown error'
const errorMessage = error?.message || t`Unknown error`
const errorStack = error?.stack || 'No stack trace available'
useEffect(() => {
logger.error('DefaultCatchBoundary | ', error)
if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error('You\'ve been logged out')
toast.error(t`You've been logged out`)
router.history.push('/login')
throw redirect({ to: '/login' })
}
@@ -53,23 +55,23 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<Center>
<Stack align="center" gap="md">
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
<Text size="xl" fw={600}>Access Denied</Text>
<Text size="xl" fw={600}><Trans>Access Denied</Trans></Text>
<Text c="dimmed" ta="center">
You don't have permission to access this page.
<Trans>You don't have permission to access this page.</Trans>
</Text>
<Group gap="sm" mt="md">
<Button
variant="light"
onClick={() => window.history.back()}
>
Go Back
<Trans>Go Back</Trans>
</Button>
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
<Trans>Home</Trans>
</MantineButton>
</Group>
</Stack>
@@ -84,21 +86,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<Stack align="center" gap="md" w="100%">
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
<Text size="xl" fw={600}>Something went wrong</Text>
<Text size="xl" fw={600}><Trans>Something went wrong</Trans></Text>
<Text c="dimmed" ta="center">
An error occurred while loading this page.
<Trans>An error occurred while loading this page.</Trans>
</Text>
<Box w="100%" mt="md">
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
<Text size="sm" c="dimmed" mb="xs"><Trans>Error: {errorMessage}</Trans></Text>
<Button
variant="subtle"
size="compact-sm"
onClick={toggleDetails}
fullWidth
>
{detailsOpened ? 'Hide' : 'Show'} details
{detailsOpened ? <Trans>Hide details</Trans> : <Trans>Show details</Trans>}
</Button>
<Collapse in={detailsOpened}>
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
@@ -112,7 +114,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
variant="light"
onClick={() => router.invalidate()}
>
Retry
<Trans>Retry</Trans>
</Button>
{isRoot ? (
<MantineButton
@@ -120,14 +122,14 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
to="/"
variant="filled"
>
Home
<Trans>Home</Trans>
</MantineButton>
) : (
<Button
variant="filled"
onClick={() => window.history.back()}
>
Go Back
<Trans>Go Back</Trans>
</Button>
)}
</Group>
+3 -1
View File
@@ -10,6 +10,7 @@ import {
} from "@mantine/core";
import { useState } from "react";
import { XIcon } from "@phosphor-icons/react";
import { useLingui } from "@lingui/react/macro";
interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
@@ -30,6 +31,7 @@ const Avatar = ({
contain = false,
...props
}: AvatarProps) => {
const { t } = useLingui();
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src);
@@ -102,7 +104,7 @@ const Avatar = ({
color="dark"
size="lg"
radius="xl"
aria-label="Close image preview"
aria-label={t`Close image preview`}
style={{
position: 'absolute',
top: -10,
+3 -1
View File
@@ -1,6 +1,7 @@
import useNow from '@/hooks/use-now';
import { Text, Group } from '@mantine/core';
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import classes from './countdown.module.css';
interface CountdownProps {
@@ -34,6 +35,7 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
const pad = (num: number) => num.toString().padStart(2, '0');
export function Countdown({ date, label, color }: CountdownProps) {
const { t } = useLingui();
const now = useNow();
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
@@ -45,7 +47,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
const prefix =
timeLeft.days > 0
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
? `${t`${timeLeft.days}d`} ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
return (
+3 -1
View File
@@ -2,6 +2,7 @@ import { DatePicker, TimeInput } from "@mantine/dates";
import { ActionIcon, Stack } from "@mantine/core";
import { useRef } from "react";
import { ClockIcon } from "@phosphor-icons/react";
import { useLingui } from "@lingui/react/macro";
interface DateTimePickerProps {
value: Date | null;
@@ -16,6 +17,7 @@ const DateTimePicker = ({
label,
...rest
}: DateTimePickerProps) => {
const { t } = useLingui();
const timeRef = useRef<HTMLInputElement>(null);
const currentDate = value ? new Date(value) : null;
@@ -73,7 +75,7 @@ const DateTimePicker = ({
/>
<TimeInput
ref={timeRef}
label="Time"
label={t`Time`}
size="md"
value={formatTime(currentDate)}
onChange={handleTimeChange}
+5 -3
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
export function IOSInstallPrompt() {
const { t } = useLingui()
const [show, setShow] = useState(false)
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
@@ -31,8 +33,8 @@ export function IOSInstallPrompt() {
if (!show || !platform) return null
const instructions = platform === 'ios'
? 'Tap Share → Add to Home Screen'
: 'Tap Menu (⋮) → Add to Home screen'
? t`Tap Share → Add to Home Screen`
: t`Tap Menu (⋮) → Add to Home screen`
return (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
@@ -42,7 +44,7 @@ export function IOSInstallPrompt() {
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
Please install FLXN • This will save me Twilio credits as you won't be signed out!
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans>
</Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions}
+4 -2
View File
@@ -1,6 +1,7 @@
import { Input, InputProps, Group, Text } from "@mantine/core";
import { CheckFat, Phone } from "@phosphor-icons/react";
import { IMaskInput } from "react-imask";
import { Trans, useLingui } from "@lingui/react/macro";
interface PhoneNumberInputProps extends InputProps {
id: string;
@@ -20,6 +21,7 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
error,
...props
}) => {
const { t } = useLingui();
return (
<Input.Wrapper
id={id}
@@ -35,13 +37,13 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
<Group gap={2}>
<Phone size={20} /> &nbsp;{" "}
<Text c="dimmed" size="sm">
+1
<Trans>+1</Trans>
</Text>
</Group>
}
leftSectionWidth={50}
leftSectionProps={{ style: { padding: 0 } }}
placeholder="(713) 867-5309"
placeholder={t`(713) 867-5309`}
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
rightSection={
value?.length === 10 && (
+30 -25
View File
@@ -1,5 +1,6 @@
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
import { PropsWithChildren, Suspense } from "react";
import { useLingui } from "@lingui/react/macro";
interface ModalProps extends PropsWithChildren {
title?: string;
@@ -15,31 +16,35 @@ const Modal: React.FC<ModalProps> = ({
opened,
onClose,
onExited,
}) => (
<MantineModal
opened={opened}
onClose={onClose}
title={<Title order={3}>{title}</Title>}
radius={20}
transitionProps={{
transition: "pop",
duration: 200,
timingFunction: "ease-out",
onExited,
}}
overlayProps={{ backgroundOpacity: 0.4 }}
closeButtonProps={{ "aria-label": "Close" }}
>
<Suspense
fallback={
<Flex justify="center" align="center" w="100%" h={400}>
<Loader size="lg" />
</Flex>
}
}) => {
const { t } = useLingui();
return (
<MantineModal
opened={opened}
onClose={onClose}
title={<Title order={3}>{title}</Title>}
radius={20}
transitionProps={{
transition: "pop",
duration: 200,
timingFunction: "ease-out",
onExited,
}}
overlayProps={{ backgroundOpacity: 0.4 }}
closeButtonProps={{ "aria-label": t`Close` }}
>
{children}
</Suspense>
</MantineModal>
);
<Suspense
fallback={
<Flex justify="center" align="center" w="100%" h={400}>
<Loader size="lg" />
</Flex>
}
>
{children}
</Suspense>
</MantineModal>
);
};
export default Modal;
@@ -1,6 +1,7 @@
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import React, { ComponentType, useContext } from "react";
import { useLingui } from "@lingui/react/macro";
import { SlidePanelContext } from "./slide-panel-context";
interface SlidePanelFieldProps {
@@ -23,12 +24,13 @@ const SlidePanelField = ({
Component,
title,
label,
placeholder = "Select value",
placeholder,
withAsterisk = false,
formatValue,
componentProps,
error,
}: SlidePanelFieldProps) => {
const { t, i18n } = useLingui();
const context = useContext(SlidePanelContext);
if (!context) {
@@ -53,11 +55,11 @@ const SlidePanelField = ({
}
if (value != null) {
if (value instanceof Date) {
return value.toLocaleDateString();
return value.toLocaleDateString(i18n.locale);
}
return String(value);
}
return placeholder;
return placeholder ?? t`Select value`;
};
return (
@@ -9,6 +9,7 @@ import {
} from "@mantine/core";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { useState, ReactNode } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
import Button from "@/components/button";
@@ -28,13 +29,16 @@ const SlidePanel = ({
children,
onSubmit,
onCancel,
submitText = "Submit",
cancelText = "Cancel",
submitText,
cancelText,
cancelColor = "red",
maxHeight = "70vh",
formProps = {},
loading = false,
}: SlidePanelProps) => {
const { t } = useLingui();
const resolvedSubmitText = submitText ?? t`Submit`;
const resolvedCancelText = cancelText ?? t`Cancel`;
const [isOpen, setIsOpen] = useState(false);
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
const [tempValue, setTempValue] = useState<any>(null);
@@ -113,7 +117,7 @@ const SlidePanel = ({
loading={loading}
disabled={loading}
>
{submitText}
{resolvedSubmitText}
</Button>
{onCancel && (
<Button
@@ -124,7 +128,7 @@ const SlidePanel = ({
type="button"
disabled={loading}
>
{cancelText}
{resolvedCancelText}
</Button>
)}
</Group>
@@ -153,7 +157,7 @@ const SlidePanel = ({
<ActionIcon
variant="transparent"
onClick={closePanel}
aria-label="Back"
aria-label={t`Back`}
>
<ArrowLeftIcon size={24} />
</ActionIcon>
@@ -162,7 +166,7 @@ const SlidePanel = ({
variant="transparent"
color="green"
onClick={handleConfirm}
aria-label="Confirm"
aria-label={t`Confirm`}
>
<CheckIcon size={24} />
</ActionIcon>
@@ -182,8 +186,8 @@ const SlidePanel = ({
/>
</ScrollArea.Autosize>
<Stack mt="auto" w="100%" gap={2}>
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
<Button mt="md" onClick={handleConfirm}><Trans>Confirm</Trans></Button>
<Button variant="subtle" onClick={closePanel} mt="sm" color="red"><Trans>Cancel</Trans></Button>
</Stack>
</>
)}
+23 -19
View File
@@ -19,6 +19,7 @@ import {
ArrowUpIcon,
ArrowDownIcon,
} from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { BaseStats } from "@/types/stats";
interface StatsOverviewProps {
@@ -62,11 +63,13 @@ const StatItem = ({
};
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
const { t } = useLingui();
if (!statsData && !isLoading) {
return (
<Box p="sm" h="auto" mih={200}>
<Text ta="center" size="sm" fw={600} c="dimmed">
No stats available yet
<Trans>No stats available yet</Trans>
</Text>
</Box>
);
@@ -96,15 +99,15 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
const allStats = [
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
{ label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
{ label: t`Matches Played`, value: overallStats.matches, Icon: BoxingGloveIcon },
{ label: t`Wins`, value: overallStats.wins, Icon: CrownIcon },
{ label: t`Losses`, value: overallStats.losses, Icon: XIcon },
{ label: t`Cups Made`, value: overallStats.total_cups_made, Icon: FireIcon },
{ label: t`Cups Against`, value: overallStats.total_cups_against, Icon: ShieldIcon },
{ label: t`Avg Cups Per Match`, value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
{ label: t`Avg Cups Against`, value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
{ label: t`Avg Win Margin`, value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
{ label: t`Avg Loss Margin`, value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
];
return (
@@ -127,16 +130,17 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
};
export const StatsSkeleton = () => {
const { t } = useLingui();
const skeletonStats = [
{ label: "Matches Played", Icon: BoxingGloveIcon },
{ label: "Wins", Icon: CrownIcon },
{ label: "Losses", Icon: XIcon },
{ label: "Cups Made", Icon: FireIcon },
{ label: "Cups Against", Icon: ShieldIcon },
{ label: "Avg Cups Per Match", Icon: ChartLineUpIcon },
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
{ label: t`Matches Played`, Icon: BoxingGloveIcon },
{ label: t`Wins`, Icon: CrownIcon },
{ label: t`Losses`, Icon: XIcon },
{ label: t`Cups Made`, Icon: FireIcon },
{ label: t`Cups Against`, Icon: ShieldIcon },
{ label: t`Avg Cups Per Match`, Icon: ChartLineUpIcon },
{ label: t`Avg Cups Against`, Icon: ShieldCheckIcon },
{ label: t`Avg Win Margin`, Icon: ArrowUpIcon },
{ label: t`Avg Loss Margin`, Icon: ArrowDownIcon },
];
return (
+6 -2
View File
@@ -12,9 +12,13 @@ import { useRouter } from "@tanstack/react-router";
interface TabItem {
label: string;
/** Stable URL slug for the ?tab= param; defaults to label. Set this when label is translated. */
value?: string;
content: ReactNode;
}
const tabValue = (tab: TabItem) => (tab.value ?? tab.label).toLowerCase();
interface SwipeableTabsProps {
tabs: TabItem[];
defaultTab?: number;
@@ -36,7 +40,7 @@ function SwipeableTabs({
const urlTab = search?.tab;
if (typeof urlTab === "string") {
const tabIndex = tabs.findIndex(
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
(tab) => tabValue(tab) === urlTab.toLowerCase()
);
return tabIndex !== -1 ? tabIndex : defaultTab;
}
@@ -62,7 +66,7 @@ function SwipeableTabs({
?.querySelector(".mantine-ScrollArea-viewport")
?.scrollTo({ top: 0 });
const tabLabel = tabs[index].label.toLowerCase();
const tabLabel = tabValue(tabs[index]);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("tab", tabLabel);
+6 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, ReactNode } from "react";
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { useLingui } from "@lingui/react/macro";
export interface TypeaheadOption<T = any> {
id: string;
@@ -24,12 +25,14 @@ const Typeahead = <T,>({
searchFn,
renderOption,
format,
placeholder = "Search...",
placeholder,
debounceMs = 300,
disabled = false,
initialValue = "",
maxHeight = 200,
}: TypeaheadProps<T>) => {
const { t } = useLingui();
const resolvedPlaceholder = placeholder ?? t`Search...`;
const [searchQuery, setSearchQuery] = useState(initialValue);
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -119,7 +122,7 @@ const Typeahead = <T,>({
}
await performSearch(searchQuery);
}}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
disabled={disabled}
/>
@@ -164,7 +167,7 @@ const Typeahead = <T,>({
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
{searchQuery.trim() ? t`No results found` : t`Start typing to search...`}
</Text>
</Box>
)}