Sube Webclient/src

This commit is contained in:
FrancoRu
2026-09-18 13:38:48 -03:00
parent 1ef9a0bd03
commit 33a2288ed0
494 changed files with 69573 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
body{
margin: 0 !important;
}
+79
View File
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '@/App';
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
// Mutable so a test can flip the session to authenticated. Hoisted because the
// vi.mock factory below runs before module imports.
const authState = vi.hoisted(() => ({
isAuthenticated: false,
role: 'Guest' as string,
}));
vi.mock('@/modules/auth/hook/auth.hook', () => ({
useAuth: () => ({
isAuthenticated: authState.isAuthenticated,
role: authState.role,
signIn: vi.fn(),
logOut: vi.fn(),
user: null,
}),
}));
afterEach(() => {
authState.isAuthenticated = false;
authState.role = 'Guest';
});
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
);
describe('App public layout chrome', () => {
it('HU-02: renders /login without header or footer', async () => {
renderAt('/login');
// Route-level pages are React.lazy-loaded (see App.tsx), so the chunk
// resolves asynchronously behind a Suspense fallback — a synchronous
// getByText would race that and fail before the real content mounts.
expect(await screen.findByText('Administrador')).toBeInTheDocument();
expect(document.querySelector('header')).toBeNull();
expect(document.querySelector('footer')).toBeNull();
});
it('HU-04: renders the 404 page without header or footer', async () => {
renderAt('/una-ruta-que-no-existe');
expect(
await screen.findByText(/no existe o fue movida/i)
).toBeInTheDocument();
expect(document.querySelector('header')).toBeNull();
expect(document.querySelector('footer')).toBeNull();
});
it('keeps header and footer on a normal public route', async () => {
renderAt('/quienes-somos');
expect(await screen.findByRole('contentinfo')).toBeInTheDocument();
expect(document.querySelector('header')).not.toBeNull();
});
it('lets an authenticated admin open a public page instead of 404', async () => {
// Regression: public slug routes (tournament/blog/team/match) used to be
// omitted entirely for authenticated users, so any public URL 404'd from
// the panel catch-all without ever hitting the API. They must resolve for
// logged-in users too, under the public layout (not the admin sidebar).
authState.isAuthenticated = true;
authState.role = UserRolesType.Admin;
renderAt('/quienes-somos');
expect(await screen.findByRole('contentinfo')).toBeInTheDocument();
expect(screen.queryByText(/no existe o fue movida/i)).toBeNull();
expect(document.querySelector('header')).not.toBeNull();
});
});
+362
View File
@@ -0,0 +1,362 @@
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '@fontsource/oswald/500.css';
import '@fontsource/oswald/600.css';
import '@fontsource/oswald/700.css';
import { lazy, ReactElement, Suspense } from 'react';
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import routes from './modules/core/constants/routes';
import { APP_ROUTES } from './modules/core/constants/appRoutes';
import { useAuth } from './modules/auth/hook/auth.hook';
import SidebarLayout from './views/core/components/SidebarLayout';
import PublicLayout from './views/core/components/PublicLayout';
import { UserRolesType } from './modules/core/enum/user/userRolesType';
import InvalidToken from './views/core/errors/invalidToken';
import Forbidden from './views/core/errors/forbidden';
import NotFound from './views/core/errors/NotFound';
import PrivateRoute from './views/core/privateRoute';
import ScrollToTop from './views/core/components/ScrollToTop';
import GlobalLoadingOverlay from './views/core/components/GlobalLoadingOverlay';
import BlockingOverlay from './views/core/components/BlockingOverlay';
// Every route-level page is loaded on demand instead of shipped in the one
// main bundle every visitor downloads on first paint — the whole admin
// panel (Jugadores, Sanciones, the tournament wizard, …) was landing in a
// public visitor's browser just to render the home page. `NotFound`,
// `Forbidden` and `InvalidToken` stay eager: `App()` can return them
// directly from an early check below, outside the <Suspense> boundary the
// <Routes> tree sits in, and they're tiny enough that splitting them buys
// nothing.
const Home = lazy(() => import('./views/home/home'));
const PublicTeamPage = lazy(() => import('./views/home/teams/PublicTeamPage'));
const PublicSanctionsPage = lazy(() => import('./views/home/sanctions/PublicSanctionsPage'));
const PublicChampionsPage = lazy(() => import('./views/home/champions/PublicChampionsPage'));
const PublicMatchPage = lazy(() => import('./views/home/matches/PublicMatchPage'));
const PublicTournamentPage = lazy(() => import('./views/home/tournaments/PublicTournamentPage'));
const PublicSeasonsPage = lazy(() => import('./views/home/seasons/PublicSeasonsPage'));
const PublicSeasonPage = lazy(() => import('./views/home/seasons/PublicSeasonPage'));
const BlogPostDetailPage = lazy(() => import('./views/blogPost/BlogPostDetailPage'));
const BlogListPage = lazy(() => import('./views/blogPost/BlogListPage'));
const AddBlogPostForm = lazy(() => import('./views/blogPost/addBlogPostForm'));
const BlogPostsPage = lazy(() => import('./views/blogPost/BlogPostsPage'));
const BlogPostEditPage = lazy(() => import('./views/blogPost/BlogPostEditPage'));
const Login = lazy(() => import('./views/auth/login'));
const HowWeAre = lazy(() => import('./views/home/howWeAre/howWeAre'));
const MedicalRecord = lazy(() => import('./views/home/information/medicalRecord'));
const Regulation = lazy(() => import('./views/home/information/regulation'));
const PlayersPage = lazy(() => import('./views/player/PlayersPage'));
const PlayerPage = lazy(() => import('./views/player/PlayerPage'));
const TeamPage = lazy(() => import('./views/team/TeamPage'));
const TournamentPage = lazy(() => import('./views/tournament/TournamentPage'));
const TournamentEditPage = lazy(() => import('./views/tournament/TournamentEditPage'));
const TournamentWizardPage = lazy(() => import('./views/tournament/wizard/TournamentWizardPage'));
const DivisionPage = lazy(() => import('./views/division/divisionPage'));
const DivisionCreatePage = lazy(() => import('./views/division/divisionCreatePage'));
const DivisionEditPage = lazy(() => import('./views/division/divisionEditPage'));
const MatchPage = lazy(() => import('./views/match/matchPage'));
const UsersPage = lazy(() => import('./views/panel/UsersPage'));
const UserDetails = lazy(() => import('./views/user/userDetails'));
const CreateUser = lazy(() => import('./views/user/createUser'));
const InviteUser = lazy(() => import('./views/user/inviteUser'));
const EditUser = lazy(() => import('./views/user/editUser'));
const ChangePasswordPage = lazy(() => import('./views/panel/ChangePasswordPage'));
const StatisticsPage = lazy(() => import('./views/panel/StatisticsPage'));
const AuditLogsPage = lazy(() => import('./views/panel/AuditLogsPage'));
const DataAdministrationPage = lazy(() => import('./views/panel/DataAdministrationPage'));
const PasswordReset = lazy(() => import('./views/auth/passwordReset'));
const ForgotPassword = lazy(() => import('./views/auth/forgotPassword'));
const ActivateAccount = lazy(() => import('./views/auth/activateAccount'));
const ClubsPage = lazy(() => import('./views/club/ClubsPage'));
const ClubHistoryPage = lazy(() => import('./views/club/ClubHistoryPage'));
const PlayerSanctionsPage = lazy(() => import('./views/playerSanction/PlayerSanctionsPage'));
const PlayerSanctionPage = lazy(() => import('./views/playerSanction/PlayerSanctionPage'));
const PlayerSanctionEditPage = lazy(() => import('./views/playerSanction/playerSanctionEditPage'));
const VenuesPage = lazy(() => import('./views/venue/VenuesPage'));
const VenuePage = lazy(() => import('./views/venue/venuePage'));
const SeasonsPage = lazy(() => import('./views/season/SeasonsPage'));
const AdminSeasonDetailPage = lazy(() => import('./views/season/AdminSeasonDetailPage'));
const FIRST_TAB_BY_ROLE: Partial<Record<UserRolesType, string>> = {
[UserRolesType.Owner]: APP_ROUTES.panelSeasons,
[UserRolesType.Admin]: APP_ROUTES.panelSeasons,
};
interface AdminRouteConfig {
path: string;
element: ReactElement;
allowedRoles?: UserRolesType[];
}
const ADMIN_ROUTES: AdminRouteConfig[] = [
{ path: APP_ROUTES.passwordReset, element: <PasswordReset /> },
{
path: APP_ROUTES.panelPlayers,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <PlayersPage />,
},
{
path: APP_ROUTES.panelPlayer.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <PlayerPage />,
},
{
path: APP_ROUTES.panelTeamDetail.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <TeamPage />,
},
{
path: APP_ROUTES.panelTournamentDetail.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <TournamentPage />,
},
{
path: APP_ROUTES.panelTournamentEdit.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <TournamentEditPage />,
},
{
path: APP_ROUTES.panelTeams,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <ClubsPage />,
},
{
path: APP_ROUTES.panelClub.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <ClubHistoryPage />,
},
{
path: APP_ROUTES.panelSanctions,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <PlayerSanctionsPage />,
},
{
path: APP_ROUTES.panelSanction.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <PlayerSanctionPage />,
},
{
path: APP_ROUTES.panelSanctionEdit.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <PlayerSanctionEditPage />,
},
{
path: APP_ROUTES.panelVenues,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <VenuesPage />,
},
{
path: APP_ROUTES.panelVenue.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <VenuePage />,
},
{
path: APP_ROUTES.panelSeasons,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <SeasonsPage />,
},
{
path: APP_ROUTES.panelSeason.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <AdminSeasonDetailPage />,
},
{
path: APP_ROUTES.panelTournamentWizard,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <TournamentWizardPage />,
},
{
path: APP_ROUTES.panelDivisionCreate,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <DivisionCreatePage />,
},
{
path: APP_ROUTES.panelDivisionEdit.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <DivisionEditPage />,
},
{
path: APP_ROUTES.panelDivision.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <DivisionPage />,
},
{
path: APP_ROUTES.panelMatch.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <MatchPage />,
},
{
path: APP_ROUTES.panelBlog,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <BlogPostsPage />,
},
{
path: APP_ROUTES.panelBlogCreate,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <AddBlogPostForm />,
},
{
path: APP_ROUTES.panelBlogEdit.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <BlogPostEditPage />,
},
{
path: APP_ROUTES.panelUsers,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <UsersPage />,
},
{
path: APP_ROUTES.panelUserCreate,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <CreateUser />,
},
{
path: APP_ROUTES.panelUserInvite,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <InviteUser />,
},
{
path: APP_ROUTES.panelUserEdit.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <EditUser />,
},
{
path: APP_ROUTES.panelUser.pattern,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <UserDetails />,
},
{
path: APP_ROUTES.panelSettings,
element: <Navigate to={APP_ROUTES.panelChangePassword} replace />,
},
{
path: APP_ROUTES.panelChangePassword,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <ChangePasswordPage />,
},
{
path: APP_ROUTES.panelEditProfile,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <EditUser />,
},
{
path: APP_ROUTES.panelStatistics,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <StatisticsPage />,
},
{
path: APP_ROUTES.panelAuditLogs,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <AuditLogsPage />,
},
{
path: APP_ROUTES.panelDataAdministration,
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
element: <DataAdministrationPage />,
},
{ path: '*', element: <NotFound /> },
];
interface PublicRouteConfig {
path: string;
element: ReactElement;
}
const PUBLIC_ROUTES: PublicRouteConfig[] = [
{ path: APP_ROUTES.passwordReset, element: <PasswordReset /> },
{ path: APP_ROUTES.home, element: <Home /> },
{ path: APP_ROUTES.quienesSomos, element: <HowWeAre /> },
{ path: APP_ROUTES.fichaMedica, element: <MedicalRecord /> },
{ path: APP_ROUTES.reglamento, element: <Regulation /> },
{ path: APP_ROUTES.publicTeam.pattern, element: <PublicTeamPage /> },
{ path: APP_ROUTES.publicSanctions, element: <PublicSanctionsPage /> },
{ path: APP_ROUTES.publicChampions, element: <PublicChampionsPage /> },
{ path: APP_ROUTES.publicMatch.pattern, element: <PublicMatchPage /> },
{ path: APP_ROUTES.publicSeasons, element: <PublicSeasonsPage /> },
{ path: APP_ROUTES.publicSeason.pattern, element: <PublicSeasonPage /> },
// No `/torneos` flat listing route: it was never linked from any nav
// (dead/unreachable, HU orphan-route audit) — every tournament is reached
// via Temporadas -> season -> tournament instead.
{
path: APP_ROUTES.publicTournament.pattern,
element: <PublicTournamentPage />,
},
{ path: APP_ROUTES.publicBlog, element: <BlogListPage /> },
{ path: APP_ROUTES.blogPost.pattern, element: <BlogPostDetailPage /> },
];
function App() {
const { isAuthenticated, role } = useAuth();
const location = useLocation();
if (location.pathname === APP_ROUTES.forbidden) return <Forbidden />;
if (location.pathname === routes.tokenInvalido) return <InvalidToken />;
const defaultTab = FIRST_TAB_BY_ROLE[role] ?? APP_ROUTES.panelUsers;
// Public pages render for EVERYONE — authenticated or not — so shareable
// slug links (public tournament HU-14, blog post HU-13, team/match, HU-15)
// keep working even while an admin is logged in. Previously the whole public
// route tree was omitted for authenticated users, so any public slug URL fell
// through to the panel catch-all and 404'd without ever hitting the API.
//
// Login (HU-02) and the 404/NotFound catch-all (HU-04) render without the
// public header/footer, so they sit outside the PublicLayout chrome. The
// admin panel is only mounted when authenticated, under one persistent
// SidebarLayout (via <Outlet />) so the sidebar survives panel navigation.
return (
<>
<ScrollToTop />
<GlobalLoadingOverlay />
<Suspense fallback={<BlockingOverlay open />}>
<Routes>
<Route element={<PublicLayout />}>
{PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
))}
</Route>
<Route path={APP_ROUTES.login} element={<Login />} />
<Route path={APP_ROUTES.forgotPassword} element={<ForgotPassword />} />
<Route path={APP_ROUTES.activate} element={<ActivateAccount />} />
{isAuthenticated && (
<Route
element={
<SidebarLayout>
<Outlet />
</SidebarLayout>
}
>
{ADMIN_ROUTES.filter(({ path }) => path !== '*').map(
({ path, element, allowedRoles }) => (
<Route
key={path}
path={path}
element={
allowedRoles ? (
<PrivateRoute allowedRoles={allowedRoles}>
{element}
</PrivateRoute>
) : (
element
)
}
/>
)
)}
<Route
path={APP_ROUTES.panel}
element={<Navigate to={defaultTab} replace />}
/>
</Route>
)}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</>
);
}
export default App;
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { categoryColor } from './categoryColor';
import { category } from './tokens';
describe('categoryColor', () => {
it('tints the masculine category with the brand orange', () => {
const { fill, ink } = categoryColor(TournamentCategory.Masculine);
expect(fill).toBe(category.masculine);
// Regression: white text on this orange is only ~2.86:1 (fails WCAG AA's
// 4.5:1) — dark ink is the one that actually reads on it, ~6.3:1.
expect(ink).toBe('#0b0f17');
});
it('tints the feminine category with the brand purple', () => {
const { fill, ink } = categoryColor(TournamentCategory.Feminine);
expect(fill).toBe('#A32CC4');
expect(ink).toBe('#f5f5f5');
});
});
@@ -0,0 +1,27 @@
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { LIGHT_INK_LUMINANCE_THRESHOLD, luminance } from './colorName';
import { category } from './tokens';
/**
* The single source for category branding hues (masculine -> orange, feminine
* -> purple) plus the ink that stays legible on top. Chips and section accents
* read from here so the masculine/feminine visual language is defined once and
* never drifts between surfaces.
*/
export interface CategoryColor {
/** The category's brand fill (#rrggbb). */
fill: string;
/** A contrasting ink (near-black on light fills, off-white on dark ones). */
ink: string;
}
/** Resolves a tournament category into its brand fill plus a legible ink. */
export const categoryColor = (cat: TournamentCategory): CategoryColor => {
const fill =
cat === TournamentCategory.Feminine ? category.feminine : category.masculine;
// Mirror resolveShirtColor's threshold/ink so contrast stays consistent.
return {
fill,
ink: luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD ? '#0b0f17' : '#f5f5f5',
};
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { isHexColor, luminance, resolveShirtColor } from './colorName';
import { brand } from './tokens';
describe('isHexColor', () => {
it('accepts #rgb and #rrggbb', () => {
expect(isHexColor('#f00')).toBe(true);
expect(isHexColor('#FF0000')).toBe(true);
});
it('rejects names, empty, and malformed values', () => {
expect(isHexColor('rojo')).toBe(false);
expect(isHexColor('')).toBe(false);
expect(isHexColor(undefined)).toBe(false);
expect(isHexColor('#12')).toBe(false);
});
});
describe('luminance', () => {
it('is near 0 for black and near 1 for white', () => {
expect(luminance('#000000')).toBeCloseTo(0, 2);
expect(luminance('#ffffff')).toBeCloseTo(1, 2);
});
});
describe('resolveShirtColor', () => {
it('keeps a valid hex and expands the short form', () => {
expect(resolveShirtColor('#FF0000').fill).toBe('#ff0000');
expect(resolveShirtColor('#fff').fill).toBe('#ffffff');
});
it('picks dark ink on a light fill and light ink on a dark fill', () => {
const white = resolveShirtColor('#ffffff');
expect(white.isLight).toBe(true);
expect(white.ink).toBe('#0b0f17');
const navy = resolveShirtColor('#0f2e6b');
expect(navy.isLight).toBe(false);
expect(navy.ink).toBe('#f5f5f5');
});
it('falls back to the navy chrome hue for non-hex or empty values', () => {
expect(resolveShirtColor('Rojo').fill).toBe(brand.navyLight);
expect(resolveShirtColor(undefined).fill).toBe(brand.navyLight);
expect(resolveShirtColor('').fill).toBe(brand.navyLight);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { brand } from './tokens';
/**
* Team shirt colors are chosen with a color picker, so the stored value is a
* `#rrggbb` hex. This module resolves that hex into a usable fill plus the ink
* color that stays legible on top of it. Anything that is not a valid hex
* (e.g. an empty value, or legacy free-text left over from before the picker)
* falls back to the navy chrome hue so a jersey never renders with no fill.
*/
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/** Expands a 3-digit hex (#abc) to its 6-digit form (#aabbcc). */
const expandHex = (hex: string): string => {
if (hex.length === 4) {
const [, r, g, b] = hex;
return `#${r}${r}${g}${g}${b}${b}`;
}
return hex;
};
/** True when the value is a valid `#rgb` or `#rrggbb` hex string. */
export const isHexColor = (value?: string | null): boolean =>
typeof value === 'string' && HEX_RE.test(value.trim());
/**
* Turns a `#rgb`/`#rrggbb` hex into an `rgba()` string at the given alpha, so a
* brand hue can be used as a translucent tint/overlay. Non-hex values fall back
* to the navy chrome hue so a surface never renders with a broken color.
*/
export const hexToRgba = (hex: string, alpha: number): string => {
const full = HEX_RE.test(hex.trim())
? expandHex(hex.trim())
: brand.navyLight;
const r = parseInt(full.slice(1, 3), 16);
const g = parseInt(full.slice(3, 5), 16);
const b = parseInt(full.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/** Relative luminance (WCAG) of a #rrggbb color, in the 0..1 range. */
export const luminance = (hex: string): number => {
const full = expandHex(hex);
const channel = (v: number): number => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const r = channel(parseInt(full.slice(1, 3), 16));
const g = channel(parseInt(full.slice(3, 5), 16));
const b = channel(parseInt(full.slice(5, 7), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/**
* The fill luminance at which our two ink colors (#0b0f17 dark, #f5f5f5
* light) give EQUAL contrast against it — below this, dark ink actually
* contrasts better even though the fill isn't "dark" in the everyday sense.
* Solving (fill+0.05)/(inkDark+0.05) = (inkLight+0.05)/(fill+0.05) for our
* actual ink luminances (~0.0047 and ~0.913) gives ≈0.1796.
*
* The previous threshold (0.55) was picked without this math and left a
* wide "medium" band — anything from ~0.18 to 0.55 luminance — defaulting to
* light ink even where dark ink was the better (sometimes WCAG-AA-passing)
* choice. The brand orange (#FF5A1F, luminance ≈0.29) was exactly such a
* case: white text on it is only 2.86:1 (fails the 4.5:1 AA minimum), while
* dark ink on it is ≈6.3:1.
*/
export const LIGHT_INK_LUMINANCE_THRESHOLD = 0.18;
export interface ResolvedColor {
/** The resolved #rrggbb fill. */
fill: string;
/** A contrasting ink (near-black on light fills, off-white on dark ones). */
ink: string;
/** True when the fill is light enough that dark ink reads better on it. */
isLight: boolean;
}
/**
* Turns a stored shirt-color value (a hex, or nothing) into a resolved fill
* plus the ink color that stays legible on top of it.
*/
export const resolveShirtColor = (value?: string | null): ResolvedColor => {
const raw = (value ?? '').trim().toLowerCase();
const fill = HEX_RE.test(raw) ? expandHex(raw) : brand.navyLight;
const isLight = luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD;
return { fill, ink: isLight ? '#0b0f17' : '#f5f5f5', isLight };
};
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_JERSEY_STYLE,
JERSEY_STYLES,
isJerseyStyle,
toJerseyStyle,
} from './jerseyStyles';
describe('jerseyStyles', () => {
it('exposes a labelled option per template', () => {
expect(JERSEY_STYLES.map(s => s.value)).toEqual([
'solid',
'stripes',
'hoops',
'diagonal',
'chevron',
'sash',
'sides',
'halves',
'circles',
'gradient',
'vneck',
'pinstripe',
'yoke',
'colorblock',
'arrow',
'camo',
'checkerboard',
'diamonds',
'star',
'triband',
'shoulder',
'splitTri',
'frame',
'crossband',
'ring',
]);
JERSEY_STYLES.forEach(s => expect(s.label.trim().length).toBeGreaterThan(0));
});
it('narrows only known styles', () => {
expect(isJerseyStyle('stripes')).toBe(true);
expect(isJerseyStyle('bogus')).toBe(false);
expect(isJerseyStyle(undefined)).toBe(false);
});
it('coerces unknown values to the default style', () => {
expect(toJerseyStyle('sash')).toBe('sash');
expect(toJerseyStyle('bogus')).toBe(DEFAULT_JERSEY_STYLE);
expect(toJerseyStyle(null)).toBe(DEFAULT_JERSEY_STYLE);
});
});
@@ -0,0 +1,81 @@
/**
* Jersey templates — the selectable "kit" patterns a team can pick, in the
* spirit of the Wikipedia team-kit diagrams (a fillable body plus a pattern
* layer). Each template is rendered by {@link JerseySvg} from the team's
* colors: a primary body color, a secondary color used for the pattern/trim,
* and — for the tri-color templates only — an optional third accent color.
* Authoring the shapes ourselves keeps them inline, tintable at runtime, and
* free of any external asset.
*/
export type JerseyStyle =
| 'solid'
| 'stripes'
| 'hoops'
| 'diagonal'
| 'chevron'
| 'sash'
| 'sides'
| 'halves'
| 'circles'
| 'gradient'
| 'vneck'
| 'pinstripe'
| 'yoke'
| 'colorblock'
| 'arrow'
| 'camo'
| 'checkerboard'
| 'diamonds'
| 'star'
| 'triband'
| 'shoulder'
| 'splitTri'
| 'frame'
| 'crossband'
| 'ring';
export interface JerseyStyleOption {
value: JerseyStyle;
/** Spanish label shown in the picker. */
label: string;
/** Whether this template uses a third accent color when one is set. */
usesTertiary?: boolean;
}
export const JERSEY_STYLES: JerseyStyleOption[] = [
{ value: 'solid', label: 'Lisa' },
{ value: 'stripes', label: 'Rayas verticales' },
{ value: 'hoops', label: 'Franjas horizontales' },
{ value: 'diagonal', label: 'Rayas diagonales' },
{ value: 'chevron', label: 'Chevrón' },
{ value: 'sash', label: 'Banda diagonal' },
{ value: 'sides', label: 'Laterales' },
{ value: 'halves', label: 'Mitades' },
{ value: 'circles', label: 'Lunares' },
{ value: 'gradient', label: 'Degradé' },
{ value: 'vneck', label: 'Cuello en V' },
{ value: 'pinstripe', label: 'Rayas finas' },
{ value: 'yoke', label: 'Canesú' },
{ value: 'colorblock', label: 'Bloque diagonal' },
{ value: 'arrow', label: 'Flecha' },
{ value: 'camo', label: 'Camuflaje' },
{ value: 'checkerboard', label: 'Cuadros' },
{ value: 'diamonds', label: 'Rombos' },
{ value: 'star', label: 'Estrella' },
{ value: 'triband', label: 'Tribanda', usesTertiary: true },
{ value: 'shoulder', label: 'Hombreras' },
{ value: 'splitTri', label: 'Tres paneles', usesTertiary: true },
{ value: 'frame', label: 'Marco', usesTertiary: true },
{ value: 'crossband', label: 'Cruz diagonal' },
{ value: 'ring', label: 'Aro numeral', usesTertiary: true },
];
export const DEFAULT_JERSEY_STYLE: JerseyStyle = 'solid';
/** Narrows an arbitrary stored string to a known {@link JerseyStyle}. */
export const isJerseyStyle = (value: unknown): value is JerseyStyle =>
typeof value === 'string' && JERSEY_STYLES.some(style => style.value === value);
/** Coerces a stored value into a valid style, defaulting when unrecognized. */
export const toJerseyStyle = (value: unknown): JerseyStyle =>
isJerseyStyle(value) ? value : DEFAULT_JERSEY_STYLE;
+123
View File
@@ -0,0 +1,123 @@
/**
* Club 12 design tokens — the single source of truth for the brand's visual
* language (dark-first, orange accent, navy "scoreboard" chrome). These values
* were previously scattered as private constants inside `theme.ts`; centralizing
* them here lets components and one-off surfaces read the same hues, radii and
* spacing the MUI theme is built from, instead of hardcoding hex strings.
*
* The MUI theme (`theme.ts`) is assembled from these tokens; prefer reading
* `theme.palette` inside components. Reach for a raw token only when you need a
* value the theme does not expose (e.g. a specific surface layer for a custom
* gradient or the jersey/hero accents).
*/
/** Brand hues. Orange is the single accent; navy is the secondary chrome hue. */
export const brand = {
orange: '#FF5A1F',
orangeLight: '#FF8A50',
orangeDark: '#C43E00',
/** Near-black ink for labels on filled orange (AA-safe, ~5.6:1). */
orangeInk: '#0B0F17',
navy: '#0F172A',
navyLight: '#1E293B',
/** The club's championship gold — used for champions, podium and finals. */
gold: '#E6A817',
goldLight: '#F5C542',
} as const;
/**
* Playoff qualification tier colors (HU-45), used to highlight the standings
* rows that qualify to each cup. Gold-silver-bronze for the top three cups,
* then the brand orange for any further cup below the podium three. Silver and
* bronze are muted metallics tuned to stay legible on the dark canvas.
*/
export const cupTier = {
gold: brand.gold,
silver: '#C7CDD6',
bronze: '#CD8E5A',
accent: brand.orange,
} as const;
/**
* Category accent colors taken from the club's own branding: masculine pieces
* are the warm orange, feminine pieces a vivid purple/magenta. Used to tint
* category chips and the masculine/feminine sections so a visitor tells them
* apart at a glance.
*/
export const category = {
masculine: brand.orange,
feminine: '#A32CC4',
} as const;
/**
* Layered dark surfaces (canvas -> paper -> raised). A deliberate three-step
* scale so depth reads through elevation, never through a colored overlay.
*/
export const surface = {
canvas: '#111827', // L0 app canvas
paper: '#1A2232', // L1 cards, drawers, app surfaces
raised: '#232D3F', // L2 inputs, menus, hovered rows
} as const;
/** Light-mode surfaces, retained for the legacy light branch of the theme. */
export const surfaceLight = {
canvas: '#F4F6F9',
paper: '#FFFFFF',
raised: '#FFFFFF',
} as const;
export const ink = {
primary: '#E7EAF0',
secondary: '#98A2B3',
primaryLight: brand.navy,
secondaryLight: '#516072',
} as const;
/** Semantic hues tuned to stay legible on the dark canvas. */
export const semantic = {
success: '#00C853',
warning: '#F5A524',
info: '#38BDF8',
error: '#d32f2f',
} as const;
export const dividerColor = {
dark: 'rgba(231, 234, 240, 0.12)',
light: 'rgba(15, 23, 42, 0.12)',
} as const;
/**
* The Club 12 logo asset bakes its dark maroon backdrop into the PNG (no alpha),
* so surfaces wrapping the logo use this matching color to read as a badge.
*/
export const logoBackground = '#4D0000';
/** The SweetAlert cancel affordance color, reused for destructive controls. */
export const cancelColor = '#d33';
/** Corner radii, in px, as a small deliberate scale. */
export const radius = {
sm: 6,
md: 8,
lg: 10,
xl: 16,
pill: 999,
} as const;
/** Base spacing unit (px). The MUI `spacing()` factor stays at the default 8. */
export const spacingUnit = 8;
/** Typeface roles. Oswald (condensed, uppercase) carries the sporting display
* voice; Roboto handles body copy and data. */
export const font = {
display: "'Oswald', sans-serif",
body: "'Roboto', sans-serif",
} as const;
/**
* Constant reserved height for a page's main content area, so a view is the
* same height while its data loads (skeleton) and once it arrives — no layout
* jump. Sized to fill the viewport below the public header and above the
* footer without forcing a scroll on an empty page.
*/
export const pageMinHeight = 'calc(100vh - 220px)';
@@ -0,0 +1,12 @@
/**
* `@g-loot/react-tournament-brackets@1.0.31-rc`'s `package.json` declares
* `"types": "dist/index.d.ts"`, but that file doesn't exist in the
* published package (only `dist/esm/index.d.ts` and `dist/cjs/index.d.ts`
* do) — a packaging bug in this pre-release build. This ambient
* declaration re-points the bare specifier at the real declaration file so
* the rest of the app can `import ... from '@g-loot/react-tournament-brackets'`
* normally. Safe to delete once a release fixes the `types` field upstream.
*/
declare module '@g-loot/react-tournament-brackets' {
export * from '@g-loot/react-tournament-brackets/dist/esm/index';
}
+37
View File
@@ -0,0 +1,37 @@
body{
margin: 0 !important;
}
/* The Quill rich-text editor's "snow" theme hardcodes toolbar icon colors
for a light background; the app is dark-only, so override them here
since Quill's classes aren't reachable through the MUI theme. */
.ql-snow .ql-stroke {
stroke: #b8bfc9 !important;
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: #b8bfc9 !important;
}
.ql-snow .ql-picker {
color: #b8bfc9 !important;
}
.ql-snow .ql-picker-options {
background-color: #1a2232 !important;
color: #b8bfc9 !important;
}
.ql-toolbar.ql-snow {
border-color: rgba(231, 234, 240, 0.12) !important;
}
.ql-container.ql-snow {
border-color: rgba(231, 234, 240, 0.12) !important;
color: #e7eaf0 !important;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
border-color: rgba(231, 234, 240, 0.12) !important;
}
+64
View File
@@ -0,0 +1,64 @@
import { ComponentType, ReactNode } from 'react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
// Side-effect import: registers the global maintenance banner against
// axiosUtils' onStatusCode(HttpStatus.ServiceUnavailable, ...) registry.
import './modules/core/utils/maintenanceBanner';
import { AuthProvider } from './modules/auth/context/auth.context';
import { BrowserRouter } from 'react-router-dom';
import { ErrorProvider } from './modules/error/context/error.context';
import { TournamentProvider } from './modules/tournament/context/tournament.context';
import { VenueProvider } from './modules/venue/context/venue.context';
import { SeasonProvider } from './modules/season/context/season.context';
import { TeamProvider } from './modules/team/context/team.context';
import { ClubProvider } from './modules/club/context/club.context';
import { UserProvider } from './modules/user/context/user.context';
import { DivisionProvider } from './modules/division/context/division.context';
import { PlayerProvider } from './modules/player/context/player.context';
import { StageProvider } from './modules/stage/context/stage.context';
import { MatchProvider } from './modules/match/context/match.context';
import { PlayerSanctionProvider } from './modules/playerSanction/context/playerSanction.context';
import { PlayerStatisticProvider } from './modules/playerStatistic/context/playerStatistic.context';
import { ScorerProvider } from './modules/scorer/context/scorer.context';
import { BlogPostProvider } from './modules/blogPost/context/blogPost.context';
import { MedicalRecordProvider } from './modules/medicalRecord/context/medicalRecord.context';
import { AuditLogProvider } from './modules/auditLog/context/auditLog.context';
import ErrorBoundary from './views/core/errors/error-boundary';
import ComposeProviders from './views/core/components/ComposeProviders';
import QueryProvider from './views/core/components/QueryProvider';
import ThemedProvider from './views/core/components/ThemedProvider';
const providers: ComponentType<{ children: ReactNode }>[] = [
ErrorBoundary,
QueryProvider,
ThemedProvider,
BrowserRouter,
ErrorProvider,
AuthProvider,
VenueProvider,
SeasonProvider,
TeamProvider,
ClubProvider,
PlayerProvider,
UserProvider,
TournamentProvider,
DivisionProvider,
StageProvider,
MatchProvider,
PlayerSanctionProvider,
ScorerProvider,
PlayerStatisticProvider,
BlogPostProvider,
MedicalRecordProvider,
AuditLogProvider,
];
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ComposeProviders providers={providers}>
<App />
</ComposeProviders>
</React.StrictMode>
);
@@ -0,0 +1,51 @@
import React, { createContext, ReactNode, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { GenericResponsePagination } from '@/modules/core/types/types';
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
import { auditLogService } from '@/modules/auditLog/service/auditLog.service';
import {
AuditLogFiltered,
IAuditLogContextProps,
IAuditLogResponse,
} from '@/modules/auditLog/type/auditLog';
import { auditLogKeys } from '@/modules/auditLog/queryKeys';
export const AuditLogContext = createContext<IAuditLogContextProps | undefined>(
undefined
);
export const AuditLogProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const queryClient = useQueryClient();
const handleUnknownError = useUnknownErrorHandler();
const getAuditLogs = useCallback(
async (
filter: AuditLogFiltered
): Promise<GenericResponsePagination<IAuditLogResponse> | void> => {
try {
const response = await queryClient.fetchQuery({
queryKey: auditLogKeys.list(filter),
queryFn: async () => await auditLogService.getAuditLogs(filter),
});
return response?.data;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[queryClient, handleUnknownError]
);
const container: IAuditLogContextProps = useMemo(
() => ({ getAuditLogs }),
[getAuditLogs]
);
return (
<AuditLogContext.Provider value={container}>
{children}
</AuditLogContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { AuditLogContext } from '@/modules/auditLog/context/auditLog.context';
export const useAuditLog = () => {
const context = useContext(AuditLogContext);
if (!context) {
throw new Error('useAuditLog must be used within an AuditLogProvider');
}
return context;
};
@@ -0,0 +1,8 @@
import { AuditLogFiltered } from '@/modules/auditLog/type/auditLog';
export const auditLogKeys = {
list: (filter?: AuditLogFiltered) =>
filter === undefined
? (['auditLog', 'list'] as const)
: (['auditLog', 'list', filter] as const),
};
@@ -0,0 +1,27 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { withTablePageSize } from '@/modules/core/constants/pagination';
import { GenericResponsePagination } from '@/modules/core/types/types';
import { sendGet } from '@/modules/core/utils/axiosUtils';
import {
AuditLogFiltered,
IAuditLogResponse,
} from '@/modules/auditLog/type/auditLog';
/**
* Read-only service for the sensitive-action audit trail (HU-101).
*/
export const auditLogService = {
/**
* Fetches audit entries (newest first) with pagination and optional filters.
* @param {AuditLogFiltered} filter - The filter criteria to apply.
* @returns {Promise<AxiosResponse<GenericResponsePagination<IAuditLogResponse>>>}
*/
getAuditLogs: async (
filter: AuditLogFiltered
): Promise<AxiosResponse<GenericResponsePagination<IAuditLogResponse>>> =>
sendGet<GenericResponsePagination<IAuditLogResponse>>(
routes.auditLogs,
withTablePageSize(filter)
),
};
@@ -0,0 +1,76 @@
import {
Filtered,
GenericResponsePagination,
GUID,
} from '@/modules/core/types/types';
/**
* The sensitive, auditable actions tracked by the backend (HU-101). Persisted
* as the enum name, so the frontend receives these exact strings.
*/
export type AuditAction =
| 'DataWipe'
| 'BackupRestore'
| 'TournamentStatusChange'
| 'PasswordReset'
| 'PlayoffDraw';
/**
* A single audit-trail entry as returned by `GET /api/audit-logs` (HU-101).
* @interface IAuditLogResponse
*/
export interface IAuditLogResponse {
/** The unique identifier of the audit entry. */
id: GUID;
/** The sensitive action that was performed (enum name). */
action: string;
/** Who performed the action (email, or "System"). */
actor: string;
/** The kind of entity targeted, when applicable. */
targetType?: string | null;
/** Identifier of the targeted entity, when applicable. */
targetId?: string | null;
/**
* The target's human-readable name/label at the moment the action was
* performed. Null for actions with no single named target, or entries
* written before this field existed (fall back to targetId for those).
*/
targetName?: string | null;
/** Free-form human-readable context. */
detail?: string | null;
/** When the action happened (UTC). */
timestamp: string;
}
/**
* Filtering and pagination for the audit-trail listing (HU-101).
* @interface AuditLogFiltered
*/
export interface AuditLogFiltered extends Filtered {
/** Optional filter by the actor (who performed the action). */
actor?: string;
/** Optional filter by the action type. */
action?: AuditAction;
}
/**
* Context surface for reading the audit trail (HU-101).
* @interface IAuditLogContextProps
*/
export interface IAuditLogContextProps {
/**
* Fetches audit entries (newest first) with pagination and optional filters.
* @param filter The filter criteria to apply.
*/
getAuditLogs(
filter: AuditLogFiltered
): Promise<GenericResponsePagination<IAuditLogResponse> | void>;
}
@@ -0,0 +1,60 @@
import { act, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import Swal from 'sweetalert2';
import { ErrorProvider } from '@/modules/error/context/error.context';
import { AuthProvider } from '@/modules/auth/context/auth.context';
import { useAuth } from '@/modules/auth/hook/auth.hook';
import { authService } from '@/modules/auth/service/auth.service';
import { ERROR_MESSAGES } from '@/modules/core/constants/constants';
vi.mock('@/modules/auth/service/auth.service');
vi.mock('sweetalert2', () => ({
default: {
fire: vi.fn(),
getContainer: vi.fn().mockReturnValue(null),
},
}));
const mockedLoginRequest = vi.mocked(authService.loginRequest);
const mockedSwalFire = vi.mocked(Swal.fire);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
<ErrorProvider>
<AuthProvider>{children}</AuthProvider>
</ErrorProvider>
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
});
describe('AuthProvider — signIn failure', () => {
it('shows exactly one Spanish toast and resolves false, never the raw backend error', async () => {
mockedLoginRequest.mockRejectedValueOnce(
new Error('Invalid credentials.')
);
const { result } = renderHook(() => useAuth(), { wrapper });
let success: boolean | undefined;
await act(async () => {
success = await result.current.signIn({
email: 'wrong@club12.test',
password: 'wrong',
});
});
expect(success).toBe(false);
expect(mockedSwalFire).toHaveBeenCalledTimes(1);
expect(mockedSwalFire).toHaveBeenCalledWith(
expect.objectContaining({
icon: 'error',
title: ERROR_MESSAGES.LOGIN_FAILED,
})
);
});
});
@@ -0,0 +1,335 @@
import { AxiosError } from 'axios';
import Cookies from 'js-cookie';
import { decodeToken } from 'react-jwt';
import React, {
createContext,
useRef,
useState,
useEffect,
useCallback,
useMemo,
} from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProviderProps } from '@/modules/core/types/types';
import { useError } from '@/modules/error/hooks/error.hock';
import { authService } from '@/modules/auth/service/auth.service';
import {
AuthResponse,
IAuthContextProps,
IUser,
LogInUserRequest,
} from '@/modules/auth/type/auth';
import {
COOKIE_SIGNIN_TOKEN,
ERROR_MESSAGES,
SUCCESS_MESSAGES,
EXPIRATION_TIME,
JWT,
} from '@/modules/core/constants/constants';
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
import { authKeys } from '@/modules/auth/queryKeys';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
export const AuthContext = createContext<IAuthContextProps | undefined>(
undefined
);
const ROLE_CLAIM =
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
const ROLE_CLAIM_LEGACY =
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role';
const ROLE_NORMALIZATION_MAP: Record<string, UserRolesType> = {
admin: UserRolesType.Admin,
administrador: UserRolesType.Admin,
owner: UserRolesType.Owner,
duenio: UserRolesType.Owner,
dueño: UserRolesType.Owner,
guest: UserRolesType.Guest,
};
const parseExpiresInToMs = (expiresIn: string): number => {
const [dayPart, timePart] = expiresIn.includes('.')
? expiresIn.split('.')
: [undefined, expiresIn];
const [hours = 0, minutes = 0, seconds = 0] = timePart.split(':').map(Number);
const days = dayPart ? Number(dayPart) : 0;
if ([days, hours, minutes, seconds].some(Number.isNaN)) {
return 0;
}
return (
days * 24 * EXPIRATION_TIME.MS_IN_HOUR +
hours * EXPIRATION_TIME.MS_IN_HOUR +
minutes * EXPIRATION_TIME.MS_IN_MINUTE +
seconds * EXPIRATION_TIME.MS_IN_SECOND
);
};
const getUserRoleFromToken = (accessToken: string): UserRolesType => {
const payload = decodeToken<Record<string, unknown>>(accessToken);
if (!payload) {
return UserRolesType.Guest;
}
const explicitRoleClaims = [
payload[ROLE_CLAIM],
payload[ROLE_CLAIM_LEGACY],
payload.role,
payload.roles,
payload.Role,
payload.Roles,
];
const dynamicRoleClaims = Object.entries(payload)
.filter(([key]) => key.toLowerCase().includes('role'))
.map(([, value]) => value);
const rawRoleValues = [...explicitRoleClaims, ...dynamicRoleClaims].flatMap(
value => (Array.isArray(value) ? value : [value])
);
const normalizedRole = rawRoleValues
.filter((value): value is string => typeof value === 'string')
.flatMap(value => value.split(','))
.map(value => value.trim().toLowerCase())
.find(value => Boolean(ROLE_NORMALIZATION_MAP[value]));
return normalizedRole
? ROLE_NORMALIZATION_MAP[normalizedRole]
: UserRolesType.Guest;
};
export const AuthProvider: React.FC<ProviderProps> = ({ children }) => {
// Both `user` (which drives `role`) and `isAuthenticated` are seeded
// synchronously from the cookie so route guards never see a false
// "not authenticated" / wrong-role flash on first render or hard reload
// while the async `hasToken` query below is still settling. That flash
// was pushing an extra /login or /forbidden history entry via
// PrivateRoute, breaking the browser back button.
const [user, setUser] = useState<IUser | null>(() => {
const token = Cookies.get(COOKIE_SIGNIN_TOKEN);
if (!token) {
return null;
}
return {
username: '',
accessToken: { accessToken: token, expiresIn: '00:00:00', refreshToken: null },
role: getUserRoleFromToken(token),
};
});
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() =>
Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN))
);
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { setError, setMessage } = useError();
const queryClient = useQueryClient();
const { data: hasToken } = useQuery({
queryKey: authKeys.hasToken(),
queryFn: async () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)),
staleTime: Infinity,
initialData: () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)),
});
const signInMutation = useMutation({
mutationFn: authService.loginRequest,
});
const logOutMutation = useMutation({
mutationFn: authService.logoutRequest,
});
const refreshTokenMutation = useMutation({
mutationFn: authService.refreshTokenRequest,
});
const clearAuthTimeout = useCallback(() => {
if (authTimeoutRef.current) {
clearTimeout(authTimeoutRef.current);
authTimeoutRef.current = null;
}
}, []);
const clearAuthStorage = useCallback(() => {
Cookies.remove(COOKIE_SIGNIN_TOKEN);
localStorage.removeItem(JWT.REFRESH_TOKEN);
setIsAuthenticated(false);
queryClient.setQueryData(authKeys.hasToken(), false);
}, [queryClient]);
const applyAuthData = useCallback(
(authData: AuthResponse, username?: string) => {
const userRole = getUserRoleFromToken(authData.accessToken);
const expiresInMs = parseExpiresInToMs(authData.expiresIn);
setUser(prevUser => ({
username: username ?? prevUser?.username ?? '',
accessToken: authData,
role: userRole,
}));
Cookies.set(
COOKIE_SIGNIN_TOKEN,
authData.accessToken,
expiresInMs > 0
? { expires: expiresInMs / (1000 * 60 * 60 * 24) }
: undefined
);
if (authData.refreshToken) {
localStorage.setItem(JWT.REFRESH_TOKEN, authData.refreshToken);
} else {
localStorage.removeItem(JWT.REFRESH_TOKEN);
}
setIsAuthenticated(true);
queryClient.setQueryData(authKeys.hasToken(), true);
return expiresInMs;
},
[queryClient]
);
const refreshAuthToken = useCallback(async (): Promise<boolean> => {
const refreshToken = localStorage.getItem(JWT.REFRESH_TOKEN);
if (!refreshToken) {
clearAuthTimeout();
clearAuthStorage();
return false;
}
try {
const res = await refreshTokenMutation.mutateAsync({ refreshToken });
if (res?.status === HttpStatus.Ok && res?.data) {
const expiresInMs = applyAuthData(res.data as AuthResponse);
clearAuthTimeout();
if (expiresInMs > 0) {
authTimeoutRef.current = setTimeout(() => {
void refreshAuthToken();
}, expiresInMs);
}
return true;
}
} catch (error: unknown) {
setError(error as AxiosError);
}
clearAuthTimeout();
clearAuthStorage();
setUser(null);
return false;
}, [
refreshTokenMutation,
applyAuthData,
clearAuthStorage,
clearAuthTimeout,
setError,
]);
useEffect(() => {
setIsAuthenticated(Boolean(hasToken));
}, [hasToken]);
const signIn = useCallback(
async (userData: LogInUserRequest): Promise<boolean> => {
try {
const res = await signInMutation.mutateAsync(userData);
if (res?.status === HttpStatus.Ok && res?.data) {
const authData = res.data as AuthResponse;
const expiresInMs = applyAuthData(authData, userData.email);
clearAuthTimeout();
if (expiresInMs > 0) {
authTimeoutRef.current = setTimeout(() => {
void refreshAuthToken();
}, expiresInMs);
}
setMessage(res.status, [SUCCESS_MESSAGES.LOGIN_SUCCESS]);
return true;
}
} catch {
// Fire the one standard Spanish toast (same as every other flow) and
// return false so the caller doesn't also show its own message. We
// deliberately do NOT surface the raw API error text here — that's
// the backend's English "Invalid credentials.", not this message.
setMessage(HttpStatus.Unauthorized, [ERROR_MESSAGES.LOGIN_FAILED]);
return false;
}
return false;
},
[
setMessage,
signInMutation,
applyAuthData,
clearAuthTimeout,
refreshAuthToken,
]
);
const logOut = useCallback(async () => {
try {
await logOutMutation.mutateAsync();
clearAuthTimeout();
clearAuthStorage();
setUser(null);
} catch (error: unknown) {
setError(error as AxiosError);
}
}, [clearAuthStorage, clearAuthTimeout, setUser, setError, logOutMutation]);
useEffect(() => {
return () => {
clearAuthTimeout();
};
}, [clearAuthTimeout]);
useEffect(() => {
if (!hasToken) {
return;
}
const token = Cookies.get(COOKIE_SIGNIN_TOKEN);
if (!token) {
return;
}
setUser(prevUser => {
if (prevUser?.accessToken?.accessToken === token) {
return prevUser;
}
const role = getUserRoleFromToken(token);
return {
username: prevUser?.username ?? '',
accessToken: {
accessToken: token,
expiresIn: prevUser?.accessToken?.expiresIn ?? '00:00:00',
refreshToken: prevUser?.accessToken?.refreshToken ?? null,
},
role,
};
});
}, [hasToken]);
const contextValue = useMemo(
() => ({
signIn,
logOut,
user,
isAuthenticated,
role: user?.role ?? UserRolesType.Guest,
}),
[signIn, logOut, user, isAuthenticated]
);
return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { AuthContext } from '@/modules/auth/context/auth.context';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used whithin an Auth Provider');
}
return context;
};
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { authKeys } from './queryKeys';
describe('authKeys', () => {
it('hasToken() returns the singleton literal', () => {
expect(authKeys.hasToken()).toEqual(['auth', 'has-token']);
});
});
@@ -0,0 +1,3 @@
export const authKeys = {
hasToken: () => ['auth', 'has-token'] as const,
};
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { authService } from '@/modules/auth/service/auth.service';
import { sendPost } from '@/modules/core/utils/axiosUtils';
vi.mock('@/modules/core/utils/axiosUtils', () => ({
sendPost: vi.fn(() => Promise.resolve({ status: 200, data: {} })),
}));
const sendPostMock = vi.mocked(sendPost);
describe('authService magic-link endpoints (HU-09/HU-10)', () => {
afterEach(() => {
sendPostMock.mockClear();
});
it('inviteRequest posts email + role to auth/invite', async () => {
await authService.inviteRequest({
email: 'nuevo@club12.com',
role: 'ADMIN',
});
expect(sendPostMock).toHaveBeenCalledWith('auth/invite', {
email: 'nuevo@club12.com',
role: 'ADMIN',
});
});
it('activateRequest posts email + token + newPassword to auth/activate', async () => {
await authService.activateRequest({
email: 'nuevo@club12.com',
token: 'activation-token',
newPassword: 'Str0ng!Pass',
});
expect(sendPostMock).toHaveBeenCalledWith('auth/activate', {
email: 'nuevo@club12.com',
token: 'activation-token',
newPassword: 'Str0ng!Pass',
});
});
it('requestPasswordResetRequest posts email to auth/password-reset/request', async () => {
await authService.requestPasswordResetRequest({
email: 'olvide@club12.com',
});
expect(sendPostMock).toHaveBeenCalledWith('auth/password-reset/request', {
email: 'olvide@club12.com',
});
});
});
@@ -0,0 +1,59 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { sendPost } from '@/modules/core/utils/axiosUtils';
import {
ActivateAccountRequest,
AuthResponse,
InviteUserRequest,
InviteUserResponse,
LogInUserRequest,
PasswordResetConfirmRequest,
RefreshTokenRequest,
RequestPasswordResetRequest,
} from '@/modules/auth/type/auth';
export const authService = {
loginRequest: (
user: LogInUserRequest
): Promise<AxiosResponse<AuthResponse> | undefined> =>
sendPost<AuthResponse>(`${routes.auth}/login`, user),
refreshTokenRequest: (
refreshToken: RefreshTokenRequest
): Promise<AxiosResponse<AuthResponse> | undefined> =>
sendPost<AuthResponse>(`${routes.auth}/refresh-token`, refreshToken),
/**
* HU-09: invites a user by email (Admin/Owner). The backend creates a
* passwordless account and emails a magic activation link.
*/
inviteRequest: (
payload: InviteUserRequest
): Promise<AxiosResponse<InviteUserResponse> | undefined> =>
sendPost<InviteUserResponse>(`${routes.auth}/invite`, payload),
/**
* HU-09: consumes the activation token from the invitation email and sets
* the invited user's first password.
*/
activateRequest: (
payload: ActivateAccountRequest
): Promise<AxiosResponse<AuthResponse> | undefined> =>
sendPost<AuthResponse>(`${routes.auth}/activate`, payload),
/**
* HU-10: self-service. Requests a password-reset magic link for the given
* email. Always resolves 200 (no account enumeration).
*/
requestPasswordResetRequest: (
payload: RequestPasswordResetRequest
): Promise<AxiosResponse<void> | undefined> =>
sendPost<void>(`${routes.auth}/password-reset/request`, payload),
confirmPasswordResetRequest: (
payload: PasswordResetConfirmRequest
): Promise<AxiosResponse<AuthResponse> | undefined> =>
sendPost<AuthResponse>(`${routes.auth}/password-reset/confirm`, payload),
logoutRequest: () => sendPost(`${routes.auth}/logout`),
};
+235
View File
@@ -0,0 +1,235 @@
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
/**
* Represents the response object for authentication tokens.
* @interface TokenResponse
*/
export interface TokenResponse {
/**
* The access token used for authentication.
* @type {string | null}
*/
accessToken: string;
/**
* The expiration duration of the access token in date-span format.
* @type {string}
*/
expiresIn: string;
/**
* The refresh token used for refreshing the access token.
* @type {string | null}
*/
refreshToken: string | null;
}
/**
* Represents the request object to refresh the access token using the refresh token.
* @interface RefreshTokenRequest
*/
export interface RefreshTokenRequest {
/**
* The refresh token used to get a new access token.
* @type {string}
*/
refreshToken: string;
}
/**
* Represents a password reset confirmation request from an email link.
* @interface PasswordResetConfirmRequest
*/
export interface PasswordResetConfirmRequest {
/**
* Email associated with the user account.
* @type {string}
*/
email: string;
/**
* Token received by email for password reset.
* @type {string}
*/
token: string;
/**
* New password to set.
* @type {string}
*/
newPassword: string;
}
/**
* HU-09: request to invite a user by email only (no password). The backend
* creates a passwordless account and emails a magic activation link. Requires
* Admin or Owner.
* @interface InviteUserRequest
*/
export interface InviteUserRequest {
/**
* Email the invitation/activation link is sent to.
* @type {string}
*/
email: string;
/**
* Optional contact phone number.
* @type {string | undefined}
*/
phone?: string;
/**
* Target role. Accepted values: ADMIN, OWNER.
* @type {string}
*/
role: string;
}
/**
* HU-09: confirmation payload returned after inviting a user by email.
* @interface InviteUserResponse
*/
export interface InviteUserResponse {
/**
* Id of the newly created (passwordless) user.
* @type {string}
*/
userId: string;
/**
* Email the activation link was sent to.
* @type {string}
*/
email: string;
/**
* Assigned role.
* @type {string}
*/
role: string;
}
/**
* HU-09: payload the invited user submits from the activation email link to
* set their first password and enable login.
* @interface ActivateAccountRequest
*/
export interface ActivateAccountRequest {
/**
* Email associated with the invited account.
* @type {string}
*/
email: string;
/**
* Activation token received by email.
* @type {string}
*/
token: string;
/**
* First password the user chooses.
* @type {string}
*/
newPassword: string;
}
/**
* HU-10: self-service request to receive a password-reset magic link by email.
* @interface RequestPasswordResetRequest
*/
export interface RequestPasswordResetRequest {
/**
* Email to send the password-reset link to.
* @type {string}
*/
email: string;
}
/**
* Represents a user login request.
* @interface LogInUserRequest
*/
export interface LogInUserRequest {
/**
* The username of the user.
* @type {string}
* @minLength 1
*/
email: string;
/**
* The password of the user.
* @type {string}
* @minLength 1
*/
password: string;
}
/**
* Represents the response containing user authentication information.
* @interface AuthResponse
*/
export type AuthResponse = TokenResponse;
/**
* Represents the authentication context properties for sign-in, sign-out, and user information.
* @interface IAuthContextProps
*/
export interface IAuthContextProps {
/**
* Sign-in method that attempts to authenticate the user.
* @param value The login credentials.
* @returns {Promise<boolean>} Whether authentication was successful.
*/
signIn: (value: LogInUserRequest) => Promise<boolean>;
/**
* Log out the current user and clear authentication state.
* @returns {Promise<void>} Returns a promise that resolves when logout is complete.
*/
logOut: () => Promise<void>;
/**
* The current authenticated user or null if no user is logged in.
* @type {IUser | null}
*/
user: IUser | null;
/**
* Boolean flag indicating whether the user is authenticated.
* @type {boolean}
*/
isAuthenticated: boolean;
/**
* The role of the current user.
* @type {UserRolesType}
*/
role: UserRolesType;
}
/**
* Represents the authenticated user details.
* @interface IUser
*/
export interface IUser {
/**
* The username of the user.
* @type {string}
*/
username: string;
/**
* The authentication response containing the user's access token.
* @type {AuthResponse}
*/
accessToken: AuthResponse;
/**
* The role of the user.
* @type {UserRolesType}
*/
role: UserRolesType;
}
@@ -0,0 +1,95 @@
/**
* Client-side mirror of the backend password policy (Identity defaults).
* Kept in one place so every password-setting screen (activation, reset,
* change password) validates the same rules and shows the same wording.
*/
export interface PasswordPolicyState {
requiredLength: boolean;
requireUppercase: boolean;
requireLowercase: boolean;
requireDigit: boolean;
requireNonAlphanumeric: boolean;
requiredUniqueChars: boolean;
}
export const getPasswordPolicyState = (password: string): PasswordPolicyState => {
const uniqueCharsCount = new Set(password).size;
return {
requiredLength: password.length >= 8,
requireUppercase: /[A-Z]/.test(password),
requireLowercase: /[a-z]/.test(password),
requireDigit: /\d/.test(password),
requireNonAlphanumeric: /[^a-zA-Z0-9]/.test(password),
requiredUniqueChars: uniqueCharsCount >= 2,
};
};
/**
* Builds the list of human-readable validation messages for a new password and
* its confirmation. Returns an empty array when everything is valid.
*/
export const buildPasswordPolicyMessages = (
newPassword: string,
confirmPassword: string
): string[] => {
const policy = getPasswordPolicyState(newPassword);
const messages: string[] = [];
if (!newPassword) {
messages.push('La nueva contraseña es obligatoria.');
}
if (!policy.requiredLength) {
messages.push('La contraseña debe tener al menos 8 caracteres.');
}
if (!policy.requireUppercase) {
messages.push('La contraseña debe contener al menos una letra mayúscula.');
}
if (!policy.requireLowercase) {
messages.push('La contraseña debe contener al menos una letra minúscula.');
}
if (!policy.requireDigit) {
messages.push('La contraseña debe contener al menos un número.');
}
if (!policy.requireNonAlphanumeric) {
messages.push(
'La contraseña debe contener al menos un carácter no alfanumérico.'
);
}
if (!policy.requiredUniqueChars) {
messages.push('La contraseña debe contener al menos 2 caracteres únicos.');
}
if (!confirmPassword) {
messages.push('La confirmación de contraseña es obligatoria.');
}
if (newPassword && confirmPassword && newPassword !== confirmPassword) {
messages.push('La confirmación no coincide con la nueva contraseña.');
}
return messages;
};
/**
* Ordered checklist rendered under the password field so the user sees which
* rules they still need to satisfy.
*/
export const PASSWORD_POLICY_RULES: {
key: keyof PasswordPolicyState;
label: string;
}[] = [
{ key: 'requiredLength', label: 'Mínimo 8 caracteres' },
{ key: 'requireUppercase', label: 'Al menos una mayúscula' },
{ key: 'requireLowercase', label: 'Al menos una minúscula' },
{ key: 'requireDigit', label: 'Al menos un número' },
{ key: 'requireNonAlphanumeric', label: 'Al menos un carácter especial' },
{ key: 'requiredUniqueChars', label: 'Al menos 2 caracteres únicos' },
];
@@ -0,0 +1,250 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { AxiosError, AxiosResponse } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useBackups } from '@/modules/backup/hook/backup.hook';
import { backupService } from '@/modules/backup/service/backup.service';
import type { IBackupRecordResponse } from '@/modules/backup/type/backup';
vi.mock('@/modules/backup/service/backup.service');
const mockedBackupService = vi.mocked(backupService, true);
const buildRecord = (
overrides: Partial<IBackupRecordResponse> = {}
): IBackupRecordResponse => ({
id: 'guid-1-aaaa-bbbb-cccc',
createdAt: '2026-08-19T10:00:00Z',
sizeBytes: 1024,
origin: 'Manual',
storagePath: 'backup-1.sql',
...overrides,
});
const buildResponse = <T,>(data: T, status = 200): AxiosResponse<T> =>
({
data,
status,
statusText: 'OK',
headers: {},
config: {},
}) as AxiosResponse<T>;
const buildAxiosError = (status: number): AxiosError =>
({
isAxiosError: true,
name: 'AxiosError',
message: `Request failed with status code ${status}`,
config: {},
response: buildResponse(undefined, status),
toJSON: () => ({}),
}) as unknown as AxiosError;
beforeEach(() => {
vi.clearAllMocks();
});
describe('useBackups — fetchBackups', () => {
it('starts with an empty list and not loading', () => {
const { result } = renderHook(() => useBackups());
expect(result.current.backups).toEqual([]);
expect(result.current.loading).toBe(false);
});
it('sets loading true during the fetch and populates backups on success', async () => {
const records = [buildRecord()];
let resolveFetch: (value: AxiosResponse<IBackupRecordResponse[]>) => void =
() => {};
mockedBackupService.getBackups.mockImplementation(
() =>
new Promise(resolve => {
resolveFetch = resolve;
})
);
const { result } = renderHook(() => useBackups());
act(() => {
void result.current.fetchBackups();
});
await waitFor(() => expect(result.current.loading).toBe(true));
act(() => {
resolveFetch(buildResponse(records));
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.backups).toEqual(records);
});
it('clears loading and leaves backups unchanged when the fetch fails', async () => {
mockedBackupService.getBackups.mockRejectedValueOnce(
buildAxiosError(500)
);
const { result } = renderHook(() => useBackups());
await act(async () => {
await result.current.fetchBackups();
});
expect(result.current.loading).toBe(false);
expect(result.current.backups).toEqual([]);
});
});
describe('useBackups — createBackup', () => {
it('sets busy during the request, refetches the catalog, and resolves true on success', async () => {
const created = buildRecord({ id: 'guid-new' });
let resolveCreate: (value: AxiosResponse<IBackupRecordResponse>) => void =
() => {};
mockedBackupService.createBackup.mockImplementation(
() =>
new Promise(resolve => {
resolveCreate = resolve;
})
);
// The server applies retention pruning, so the hook must re-read the
// authoritative catalog instead of optimistically prepending the new row.
mockedBackupService.getBackups.mockResolvedValue(buildResponse([created]));
const { result } = renderHook(() => useBackups());
let createPromise: Promise<boolean>;
act(() => {
createPromise = result.current.createBackup();
});
await waitFor(() => expect(result.current.busy).toBe(true));
await act(async () => {
resolveCreate(buildResponse(created));
await createPromise;
});
await expect(createPromise!).resolves.toBe(true);
expect(result.current.busy).toBe(false);
expect(mockedBackupService.getBackups).toHaveBeenCalled();
expect(result.current.backups).toEqual([created]);
});
it('resolves false and leaves the list unchanged when the server is busy (409)', async () => {
mockedBackupService.createBackup.mockRejectedValueOnce(
buildAxiosError(409)
);
const { result } = renderHook(() => useBackups());
let created: boolean = true;
await act(async () => {
created = await result.current.createBackup();
});
expect(created).toBe(false);
expect(result.current.busy).toBe(false);
expect(result.current.backups).toEqual([]);
});
});
describe('useBackups — deleteBackup', () => {
it('removes the deleted record from the list and resolves true on success', async () => {
const record = buildRecord();
mockedBackupService.getBackups.mockResolvedValueOnce(
buildResponse([record])
);
mockedBackupService.deleteBackup.mockResolvedValueOnce(
buildResponse(undefined, 204)
);
const { result } = renderHook(() => useBackups());
await act(async () => {
await result.current.fetchBackups();
});
let deleted: boolean = false;
await act(async () => {
deleted = await result.current.deleteBackup(record.id);
});
expect(deleted).toBe(true);
expect(result.current.backups).toEqual([]);
});
it('resolves false and leaves the list unchanged when the record no longer exists (404)', async () => {
const record = buildRecord();
mockedBackupService.getBackups.mockResolvedValueOnce(
buildResponse([record])
);
mockedBackupService.deleteBackup.mockRejectedValueOnce(
buildAxiosError(404)
);
const { result } = renderHook(() => useBackups());
await act(async () => {
await result.current.fetchBackups();
});
let deleted: boolean = true;
await act(async () => {
deleted = await result.current.deleteBackup(record.id);
});
expect(deleted).toBe(false);
expect(result.current.backups).toEqual([record]);
});
});
describe('useBackups — restoreBackup', () => {
it('refetches the catalog after a successful restore and resolves true', async () => {
// A restore replays a full-schema dump, so the real catalog reverts to the
// restored snapshot's state — the hook must re-read it, never trust the
// pre-restore safety-backup record the endpoint returns.
const restoredCatalog = [buildRecord({ id: 'guid-from-snapshot' })];
mockedBackupService.restoreBackup.mockResolvedValueOnce(
buildResponse(buildRecord({ id: 'guid-safety', origin: 'Job' }))
);
mockedBackupService.getBackups.mockResolvedValueOnce(
buildResponse(restoredCatalog)
);
const { result } = renderHook(() => useBackups());
let restored: boolean = false;
await act(async () => {
restored = await result.current.restoreBackup('guid-target');
});
expect(restored).toBe(true);
expect(mockedBackupService.getBackups).toHaveBeenCalled();
expect(result.current.backups).toEqual(restoredCatalog);
});
it('sets busy true while in flight and resolves false on failure (500)', async () => {
let rejectRestore: (error: AxiosError) => void = () => {};
mockedBackupService.restoreBackup.mockImplementation(
() =>
new Promise((_, reject) => {
rejectRestore = reject;
})
);
const { result } = renderHook(() => useBackups());
let restorePromise: Promise<boolean>;
act(() => {
restorePromise = result.current.restoreBackup('guid-target');
});
await waitFor(() => expect(result.current.busy).toBe(true));
await act(async () => {
rejectRestore(buildAxiosError(500));
await restorePromise;
});
await expect(restorePromise!).resolves.toBe(false);
expect(result.current.busy).toBe(false);
expect(result.current.backups).toEqual([]);
});
});
@@ -0,0 +1,99 @@
import { useCallback, useState } from 'react';
import { backupService } from '@/modules/backup/service/backup.service';
import { IBackupRecordResponse } from '@/modules/backup/type/backup';
export interface UseBackupsResult {
backups: IBackupRecordResponse[];
loading: boolean;
busy: boolean;
fetchBackups: () => Promise<void>;
createBackup: () => Promise<boolean>;
deleteBackup: (id: string) => Promise<boolean>;
restoreBackup: (id: string) => Promise<boolean>;
}
/**
* Plain state hook (not a context provider) owning the backup catalog for
* the admin panel. `BackupsTable` is currently its only consumer, so a
* shared context/provider would add indirection nothing else needs.
*/
export const useBackups = (): UseBackupsResult => {
const [backups, setBackups] = useState<IBackupRecordResponse[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
// Pull the authoritative catalog from the server. Errors are swallowed: the
// caller keeps the previous list and page-level notify* handles messaging.
const refreshCatalog = useCallback(async (): Promise<void> => {
try {
const response = await backupService.getBackups();
setBackups(response.data);
} catch {
// keep the previous list
}
}, []);
const fetchBackups = useCallback(async (): Promise<void> => {
setLoading(true);
try {
await refreshCatalog();
} finally {
setLoading(false);
}
}, [refreshCatalog]);
const createBackup = useCallback(async (): Promise<boolean> => {
setBusy(true);
try {
await backupService.createBackup();
// Refetch, not an optimistic prepend: a manual backup applies server-side
// retention pruning, so the new row is not the only change to the catalog.
await refreshCatalog();
return true;
} catch {
return false;
} finally {
setBusy(false);
}
}, [refreshCatalog]);
const deleteBackup = useCallback(async (id: string): Promise<boolean> => {
setBusy(true);
try {
await backupService.deleteBackup(id);
setBackups(prev => prev.filter(backup => backup.id !== id));
return true;
} catch {
return false;
} finally {
setBusy(false);
}
}, []);
const restoreBackup = useCallback(async (id: string): Promise<boolean> => {
setBusy(true);
try {
await backupService.restoreBackup(id);
// Refetch, never optimistic: a restore replays a full-schema dump
// (BackupRecords included), so the catalog reverts to the restored
// snapshot's state — later backups, later deletions and the just-created
// pre-restore safety backup are all gone from the real table.
await refreshCatalog();
return true;
} catch {
return false;
} finally {
setBusy(false);
}
}, [refreshCatalog]);
return {
backups,
loading,
busy,
fetchBackups,
createBackup,
deleteBackup,
restoreBackup,
};
};
@@ -0,0 +1,51 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { sendDelete, sendGet, sendPost } from '@/modules/core/utils/axiosUtils';
import { IBackupRecordResponse } from '@/modules/backup/type/backup';
/**
* Admin-only tools for generating, listing, deleting and restoring database
* backups, plus the escape hatch for the maintenance-mode window a restore
* opens.
*/
export const backupService = {
/**
* Retrieves every catalogued backup, newest first.
* @returns {Promise<AxiosResponse<IBackupRecordResponse[]>>} The server response.
*/
getBackups: async (): Promise<AxiosResponse<IBackupRecordResponse[]>> =>
await sendGet(routes.backups),
/**
* Triggers an on-demand (manual) backup.
* @returns {Promise<AxiosResponse<IBackupRecordResponse>>} The server response containing the new backup.
*/
createBackup: async (): Promise<AxiosResponse<IBackupRecordResponse>> =>
await sendPost(routes.backups),
/**
* Deletes a catalogued backup by its ID.
* @param {string} id - The ID of the backup to delete.
* @returns {Promise<AxiosResponse<void>>} The server response.
*/
deleteBackup: async (id: string): Promise<AxiosResponse<void>> =>
await sendDelete(`${routes.backups}/${id}`),
/**
* Restores the database from a catalogued backup. The server takes an
* automatic safety backup of the current state first and returns it.
* @param {string} id - The ID of the backup to restore from.
* @returns {Promise<AxiosResponse<IBackupRecordResponse>>} The server response containing the safety backup.
*/
restoreBackup: async (
id: string
): Promise<AxiosResponse<IBackupRecordResponse>> =>
await sendPost(`${routes.backups}/${id}/restore`),
/**
* Force-exits maintenance mode, in case it is stuck active.
* @returns {Promise<AxiosResponse<void>>} The server response.
*/
exitMaintenance: async (): Promise<AxiosResponse<void>> =>
await sendDelete(routes.maintenance),
};
+62
View File
@@ -0,0 +1,62 @@
/**
* A single catalogued backup, as returned by the backend catalog. Mirrors
* `Application/DTOs/Backup/Response/BackupRecordResponse.cs`.
* @interface IBackupRecordResponse
*/
export interface IBackupRecordResponse {
/**
* The unique identifier of the backup record.
* @type {string}
*/
id: string;
/**
* ISO timestamp of when the backup was created (Fecha).
* @type {string}
*/
createdAt: string;
/**
* The size of the backup file in bytes (Peso).
* @type {number}
*/
sizeBytes: number;
/**
* How the backup was created (Forma de creación): a manual on-demand
* request or an automated scheduled job.
* @type {'Manual' | 'Job'}
*/
origin: 'Manual' | 'Job';
/**
* The storage key used to locate the backup file.
* @type {string}
*/
storagePath: string;
}
/**
* Current maintenance-mode state, as returned by the backend. Mirrors
* `Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs`.
* @interface IMaintenanceStatusResponse
*/
export interface IMaintenanceStatusResponse {
/**
* Whether the database is currently in maintenance mode.
* @type {boolean}
*/
isActive: boolean;
/**
* The reason maintenance mode was entered, if active.
* @type {string | null}
*/
reason: string | null;
/**
* ISO timestamp of when maintenance mode was entered, if active.
* @type {string | null}
*/
enteredAtUtc: string | null;
}
@@ -0,0 +1,34 @@
/**
* Spanish display labels for each `IBackupRecordResponse.origin` value,
* shown in the "Forma de creación" table column.
*/
export const BACKUP_ORIGIN_LABELS: Record<'Manual' | 'Job', string> = {
Manual: 'Manual',
Job: 'Programado',
};
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
/**
* Formats a byte count into a human-readable string (e.g. `1536` → `1.5 KB`),
* shown in the "Peso" table column.
* @param {number} bytes - The size in bytes.
* @returns {string} The formatted, human-readable size.
*/
export const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const exponent = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
UNITS.length - 1
);
const value = bytes / Math.pow(1024, exponent);
// Round to 1 decimal, then drop a trailing ".0" so whole numbers (e.g.
// exactly 2 KB) read as "2 KB" instead of "2.0 KB".
const formattedValue =
exponent === 0 ? value.toString() : value.toFixed(1).replace(/\.0$/, '');
return `${formattedValue} ${UNITS[exponent]}`;
};
@@ -0,0 +1,7 @@
export const BLOG_EXCERPT_LENGTH = 150;
export const BLOG_HOME_EXCERPT_LENGTH = 160;
// Well under the Slug column's 220-char limit (Slug is derived from Title
// and can get a "-2" suffix for uniqueness), so a max-length title can never
// overflow it.
export const BLOG_TITLE_MAX_LENGTH = 150;
@@ -0,0 +1,53 @@
import { act, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import Swal from 'sweetalert2';
import { ErrorProvider } from '@/modules/error/context/error.context';
import { BlogPostProvider } from '@/modules/blogPost/context/blogPost.context';
import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook';
import { blogPostService } from '@/modules/blogPost/service/blogPost.service';
vi.mock('@/modules/blogPost/service/blogPost.service');
vi.mock('sweetalert2', () => ({
default: {
fire: vi.fn(),
getContainer: vi.fn().mockReturnValue(null),
},
}));
const mockedAddBlogPost = vi.mocked(blogPostService.addBlogPost);
const mockedSwalFire = vi.mocked(Swal.fire);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
<ErrorProvider>
<BlogPostProvider>{children}</BlogPostProvider>
</ErrorProvider>
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
});
describe('BlogPostProvider — no duplicate success toast', () => {
/**
* addBlogPostForm.tsx already shows its own confirmation. The context used
* to ALSO fire a toast ("Blog Post created successfully" — also the only
* English string in these flows), so the user saw two modals for one save.
*/
it('does not fire its own toast after addBlogPost succeeds', async () => {
mockedAddBlogPost.mockResolvedValueOnce({
status: 201,
data: { id: '77777777-7777-7777-7777-777777777777' },
} as never);
const { result } = renderHook(() => useBlogPost(), { wrapper });
await act(async () => {
await result.current.addBlogPost({} as never);
});
expect(mockedSwalFire).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,207 @@
import { AxiosError, AxiosResponse } from 'axios';
import React, { createContext, ReactNode, useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
FetchOptions,
GenericResponsePagination,
GUID,
} from '@/modules/core/types/types';
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
import { blogPostService } from '@/modules/blogPost/service/blogPost.service';
import {
BlogPostResponse,
CreateBlogPostRequest,
GetBlogPostsFilteredRequest,
IBlogPostContextProps,
UpdateBlogPostRequest,
} from '@/modules/blogPost/type/blogPost';
import { blogPostKeys } from '@/modules/blogPost/queryKeys';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
export const BlogPostContext = createContext<IBlogPostContextProps | undefined>(
undefined
);
export const BlogPostProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const queryClient = useQueryClient();
const handleUnknownError = useUnknownErrorHandler();
const addBlogPostMutation = useMutation({
mutationFn: blogPostService.addBlogPost,
});
const putBlogPostMutation = useMutation({
mutationFn: ({ id, post }: { id: GUID; post: UpdateBlogPostRequest }) =>
blogPostService.putBlogPostById(id, post),
});
const putPhotoBlogPostMutation = useMutation({
mutationFn: ({ id, photo }: { id: GUID; photo: File }) =>
blogPostService.putPhotoBlogPostById(id, photo),
});
const deleteBlogPostMutation = useMutation({
mutationFn: blogPostService.deleteBlogPostById,
});
const addBlogPost = useCallback(
async (post: CreateBlogPostRequest): Promise<BlogPostResponse | void> => {
try {
const response: AxiosResponse<BlogPostResponse> =
await addBlogPostMutation.mutateAsync(post);
if (response && response.data) {
// Success feedback belongs to the calling page (addBlogPostForm.tsx
// shows its own confirmation) — a toast here too means two modals.
queryClient.setQueryData(
blogPostKeys.byId(response.data.id),
response
);
await queryClient.invalidateQueries({
queryKey: blogPostKeys.list(),
});
return response.data;
}
throw new AxiosError(
'Respuesta del servidor inválida.',
undefined,
undefined,
response
);
} catch (error: unknown) {
handleUnknownError(error);
}
},
[addBlogPostMutation, queryClient, handleUnknownError]
);
/**
* Updates a blog post by id. When the response is 204 No Content there is
* no body to cache — the invalidation below is enough to refresh callers.
*/
const putBlogPostById = useCallback(
async (
id: GUID,
post: UpdateBlogPostRequest
): Promise<BlogPostResponse | void> => {
try {
const response = await putBlogPostMutation.mutateAsync({ id, post });
if (response) {
if (response.status === HttpStatus.NoContent) {
// no-op
} else if (response.data) {
queryClient.setQueryData(blogPostKeys.byId(id), response);
return response.data;
}
await queryClient.invalidateQueries({
queryKey: blogPostKeys.list(),
});
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[putBlogPostMutation, queryClient, handleUnknownError]
);
const putPhotoBlogPostById = useCallback(
async (id: GUID, photo: File): Promise<BlogPostResponse | void> => {
try {
await putPhotoBlogPostMutation.mutateAsync({ id, photo });
// The photo endpoint returns no body and each upload lands at a new
// unique URL, so the fresh photoUrl is only knowable via a real GET —
// mirrors venue.context.tsx's putVenuePhotoById fix for the same gap.
const res: AxiosResponse<BlogPostResponse> =
await blogPostService.getBlogPostsById(id);
queryClient.setQueryData(blogPostKeys.byId(id), res);
await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() });
return res.data;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[putPhotoBlogPostMutation, queryClient, handleUnknownError]
);
const getBlogPostsById = useCallback(
async (
idOrSlug: string,
options?: FetchOptions
): Promise<BlogPostResponse | void> => {
try {
const response = await queryClient.fetchQuery({
queryKey: blogPostKeys.byId(idOrSlug),
queryFn: async () => await blogPostService.getBlogPostsById(idOrSlug),
});
return response?.data;
} catch (error: unknown) {
if (!options?.silent) handleUnknownError(error);
}
},
[queryClient, handleUnknownError]
);
const getBlogPostsByFilters = useCallback(
async (
filter: GetBlogPostsFilteredRequest,
options?: FetchOptions
): Promise<GenericResponsePagination<BlogPostResponse> | void> => {
try {
const response = await queryClient.fetchQuery({
queryKey: blogPostKeys.list(filter),
queryFn: async () =>
await blogPostService.getBlogPostsByFilters(filter),
});
return response?.data;
} catch (error: unknown) {
if (!options?.silent) handleUnknownError(error);
}
},
[queryClient, handleUnknownError]
);
const deleteBlogPostById = useCallback(
async (id: GUID): Promise<boolean> => {
try {
await deleteBlogPostMutation.mutateAsync(id);
queryClient.removeQueries({ queryKey: blogPostKeys.byId(id) });
await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() });
return true;
} catch (error: unknown) {
handleUnknownError(error);
return false;
}
},
[deleteBlogPostMutation, queryClient, handleUnknownError]
);
const container: IBlogPostContextProps = useMemo(
() => ({
addBlogPost,
putBlogPostById,
putPhotoBlogPostById,
getBlogPostsById,
getBlogPostsByFilters,
deleteBlogPostById,
}),
[
addBlogPost,
putBlogPostById,
putPhotoBlogPostById,
getBlogPostsById,
getBlogPostsByFilters,
deleteBlogPostById,
]
);
return (
<BlogPostContext.Provider value={container}>
{children}
</BlogPostContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { BlogPostContext } from '@/modules/blogPost/context/blogPost.context';
export const useBlogPost = () => {
const context = useContext(BlogPostContext);
if (!context) {
throw new Error('useBlogPost must be used within a BlogPostProvider');
}
return context;
};
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { blogPostKeys } from './queryKeys';
import { GUID } from '@/modules/core/types/types';
import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost';
describe('blogPostKeys', () => {
const id: GUID = '11111111-1111-1111-1111-111111111111';
it('list() returns the bare list literal with no trailing undefined', () => {
expect(blogPostKeys.list()).toEqual(['blogPost', 'list']);
});
it('list(filter) returns the filtered list literal', () => {
const filter: GetBlogPostsFilteredRequest = {
author: 'jane',
pageNumber: 1,
};
expect(blogPostKeys.list(filter)).toEqual(['blogPost', 'list', filter]);
});
it('byId(id) returns the by-id literal', () => {
expect(blogPostKeys.byId(id)).toEqual(['blogPost', 'byId', id]);
});
});
@@ -0,0 +1,9 @@
import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost';
export const blogPostKeys = {
list: (filter?: GetBlogPostsFilteredRequest) =>
filter === undefined
? (['blogPost', 'list'] as const)
: (['blogPost', 'list', filter] as const),
byId: (idOrSlug: string) => ['blogPost', 'byId', idOrSlug] as const,
};
@@ -0,0 +1,120 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { withTablePageSize } from '@/modules/core/constants/pagination';
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
import {
sendDelete,
sendGet,
sendPost,
sendPut,
} from '@/modules/core/utils/axiosUtils';
import {
BlogPostResponse,
CreateBlogPostRequest,
GetBlogPostsFilteredRequest,
UpdateBlogPostRequest,
} from '@/modules/blogPost/type/blogPost';
/**
* BlogPostService provides methods to interact with the blog posts API.
*/
export const blogPostService = {
/**
* Adds a new blog post.
* @param {CreateBlogPostRequest} post - The post data to be added.
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the server response.
*/
addBlogPost: (
post: CreateBlogPostRequest
): Promise<AxiosResponse<BlogPostResponse>> => {
const formData = new FormData();
formData.append('Author', post.author);
formData.append('Title', post.title);
formData.append('MarkdownText', post.markdownText);
// HU-16: only override the server default (published) when the author
// explicitly chose a draft, keeping the create request backward-compatible.
if (post.isPublished !== undefined) {
formData.append('IsPublished', String(post.isPublished));
}
if (post.photoFile) {
formData.append('PhotoFile', post.photoFile as Blob);
}
return sendPost<BlogPostResponse>(routes.blogposts, formData);
},
/**
* Updates an existing blog post by its ID.
* @param {string} id - The ID of the blog post to be updated.
* @param {UpdateBlogPostRequest} post - The updated post data.
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the server response.
*/
putBlogPostById: async (
id: GUID,
post: UpdateBlogPostRequest
): Promise<AxiosResponse<BlogPostResponse>> => {
const formData = new FormData();
if (post.author) formData.append('Author', post.author);
if (post.title) formData.append('Title', post.title);
if (post.markdownText) formData.append('MarkdownText', post.markdownText);
// HU-16: forward the publication state only when the caller set it, so an
// edit that does not touch the draft/published toggle leaves it unchanged.
if (post.isPublished !== undefined) {
formData.append('IsPublished', String(post.isPublished));
}
return sendPut<BlogPostResponse>(`${routes.blogposts}/${id}`, formData);
},
/**
* Updates the photo of an existing blog post by its ID.
* @param {string} id - The ID of the blog post.
* @param {File} photo - The new photo file to be uploaded.
* @returns {Promise<AxiosResponse<void>>} - A promise that resolves with the server response.
*/
putPhotoBlogPostById: async (
id: GUID,
photo: File
): Promise<AxiosResponse<void>> => {
const formData = new FormData();
formData.append('PhotoFile', photo);
return sendPut<void>(`${routes.blogposts}/${id}/photo`, formData);
},
/**
* Gets a blog post by its ID or its public slug.
* @param {string} idOrSlug - The ID or slug of the blog post to retrieve.
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the blog post data.
*/
getBlogPostsById: async (
idOrSlug: string
): Promise<AxiosResponse<BlogPostResponse>> =>
sendGet<BlogPostResponse>(`${routes.blogposts}/${idOrSlug}`),
/**
* Fetches blog posts based on filters and pagination.
* @param filter The filter criteria to apply when fetching blog posts.
* @returns A promise that resolves with a paginated response containing filtered blog posts.
*/
getBlogPostsByFilters: async (
filter: GetBlogPostsFilteredRequest
): Promise<AxiosResponse<GenericResponsePagination<BlogPostResponse>>> =>
sendGet<GenericResponsePagination<BlogPostResponse>>(
routes.blogposts,
withTablePageSize(filter)
),
/**
* Deletes a blog post by its ID.
* @param {string} id - The ID of the blog post to delete.
* @returns {Promise<AxiosResponse<void>>} - A promise that resolves when the blog post is deleted.
*/
deleteBlogPostById: async (id: GUID): Promise<AxiosResponse<void>> =>
sendDelete<void>(`${routes.blogposts}/${id}`),
};
+226
View File
@@ -0,0 +1,226 @@
import {
FetchOptions,
Filtered,
GenericResponsePagination,
} from '@/modules/core/types/types';
/**
* Context properties and methods for managing blog posts in a React application.
* These methods interact with the backend for creating, updating, fetching, and deleting blog posts.
* @interface IBlogPostContextProps
*/
export interface IBlogPostContextProps {
/**
* Adds a new blog post.
* @param post The details of the blog post to add.
* @returns A promise that resolves with the response containing the newly added blog post.
*/
addBlogPost(post: CreateBlogPostRequest): Promise<BlogPostResponse | void>;
/**
* Updates an existing blog post by its ID.
* @param id The ID of the blog post to update.
* @param post The updated blog post data.
* @returns A promise that resolves with the response containing the updated blog post.
*/
putBlogPostById(
id: GUID,
post: UpdateBlogPostRequest
): Promise<BlogPostResponse | void>;
/**
* Updates the photo of an existing blog post by its ID.
* @param id The ID of the blog post to update the photo for.
* @param photo The new photo file to upload.
* @returns A promise that resolves when the photo is successfully updated.
*/
putPhotoBlogPostById(id: GUID, photo: File): Promise<BlogPostResponse | void>;
/**
* Fetches a blog post by its ID or its public slug.
* @param idOrSlug The ID or slug of the blog post to fetch.
* @returns A promise that resolves with the blog post data.
*/
getBlogPostsById(
idOrSlug: string,
options?: FetchOptions
): Promise<BlogPostResponse | void>;
/**
* Fetches blog posts based on filters and pagination.
* @param filter The filter criteria to apply when fetching blog posts.
* @param options Per-call options; `silent` suppresses the global alert on failure.
* @returns A promise that resolves with a paginated response containing filtered blog posts.
*/
getBlogPostsByFilters(
filter: GetBlogPostsFilteredRequest,
options?: FetchOptions
): Promise<GenericResponsePagination<BlogPostResponse> | void>;
/**
* Deletes a blog post by its ID.
* @param id The ID of the blog post to delete.
* @returns A promise resolving to `true` if the blog post was deleted,
* `false` if the request failed (the global error is already reported
* either way).
*/
deleteBlogPostById(id: GUID): Promise<boolean>;
}
/**
* The request body structure for adding a new blog post.
* @interface CreateBlogPostRequest
*/
export interface CreateBlogPostRequest {
/**
* The author of the blog post.
* @type {string}
*/
author: string;
/**
* The title of the blog post.
* @type {string}
*/
title: string;
/**
* The photo file to upload for the blog post (optional).
* @type {File}
*/
photoFile?: File;
/**
* The markdown text content of the blog post.
* @type {string}
*/
markdownText: string;
/**
* Whether the post is published (visible publicly) or saved as a draft
* (HU-16). Defaults to published when omitted.
* @type {boolean}
*/
isPublished?: boolean;
}
/**
* The request body structure for updating an existing blog post.
* @interface UpdateBlogPostRequest
*/
export interface UpdateBlogPostRequest {
/**
* The updated title of the blog post.
* @type {string}
*/
title?: string;
/**
* The updated markdown text content of the blog post.
* @type {string}
*/
markdownText?: string;
/**
* The updated author of the blog post.
* @type {string}
*/
author?: string;
/**
* The updated publication state (HU-16). `undefined` leaves the current
* state untouched; `true` publishes, `false` turns it back into a draft.
* @type {boolean}
*/
isPublished?: boolean;
}
/**
* The request body structure for updating the photo of an existing blog post.
* @interface UpdateBlogPostPhotoRequest
*/
export interface UpdateBlogPostPhotoRequest {
/**
* The file of the blog post photo to be updated.
* @type {File}
*/
photoFile: File;
}
/**
* The response structure for a blog post, including its details and metadata.
* @interface BlogPostResponse
*/
export interface BlogPostResponse {
/**
* The unique identifier of the blog post.
* @type {string}
*/
id: GUID;
/**
* The author of the blog post.
* @type {string}
*/
author: string;
/**
* The title of the blog post.
* @type {string}
*/
title: string;
/**
* The unique, URL-friendly identifier used in public blog post links.
* @type {string}
*/
slug: string;
/**
* The number of views the blog post has received.
* @type {number}
*/
views: number;
/**
* The URL of the photo associated with the blog post.
* @type {string}
*/
photoUrl?: string;
/**
* The markdown text content of the blog post.
* @type {string}
*/
markdownText: string;
/**
* The date and time the blog post was created.
* @type {Date}
*/
createdAt: Date;
/**
* Whether the post is published (visible publicly) or a draft (HU-16).
* @type {boolean}
*/
isPublished: boolean;
}
/**
* The request body structure for fetching filtered blog posts with pagination.
* @interface GetBlogPostsFilteredRequest
*/
export interface GetBlogPostsFilteredRequest extends Filtered {
/**
* The author to filter blog posts by.
* @type {string}
*/
author?: string;
/**
* The title to filter blog posts by.
* @type {string}
*/
title?: string;
}
@@ -0,0 +1,36 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { GUID } from '@/modules/core/types/types';
import { sendGet } from '@/modules/core/utils/axiosUtils';
import {
IChampionHistory,
IPodium,
} from '@/modules/champion/type/champion.d';
/**
* Service for public champions/podium data (read-only).
*/
export const championService = {
/**
* Fetches the podium (top three per division) of a tournament by its id or
* public slug. Returns one entry per division; a place is `null` until it is
* decided.
* @param {string} idOrSlug - Tournament id or slug.
* @returns {Promise<AxiosResponse<IPodium[]>>} The server response.
*/
getTournamentChampions: async (
idOrSlug: string
): Promise<AxiosResponse<IPodium[]>> =>
sendGet(`${routes.tournaments}/${idOrSlug}/champions`),
/**
* Fetches the public champions history (finished tournaments only),
* optionally scoped to a single season.
* @param {GUID} [seasonId] - Optional season to filter by.
* @returns {Promise<AxiosResponse<IChampionHistory[]>>} The server response.
*/
getChampionsHistory: async (
seasonId?: GUID
): Promise<AxiosResponse<IChampionHistory[]>> =>
sendGet(routes.champions, seasonId ? { seasonId } : undefined),
};
@@ -0,0 +1,52 @@
import { GUID } from '@/modules/core/types/types';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
/**
* A single team occupying a podium place. Mirrors the backend's minimal team
* projection (id + display name + optional logo) used for champions/podium.
*/
export interface IPodiumTeam {
teamId: GUID;
teamName: string;
logoUrl: string | null;
}
/**
* The top-three finish of a single division (HU-Champions). `first`/`second`/
* `third` are `null` until that place is decided. `hasPlayoff` distinguishes a
* podium crowned by a playoff bracket from one read straight off the final
* standings — both are valid top-threes.
*/
export interface IPodium {
divisionId: GUID;
divisionName: string;
hasPlayoff: boolean;
first: IPodiumTeam | null;
second: IPodiumTeam | null;
third: IPodiumTeam | null;
}
/**
* A single champion entry in the public history (only finished tournaments).
* `category` is the raw backend enum name ("Masculine"/"Feminine") — display it
* through `TOURNAMENT_CATEGORY_LABELS`.
*/
export interface IChampionHistory {
tournamentId: GUID;
tournamentName: string;
seasonName: string | null;
/**
* The calendar year of the season, or null when there is no season or it
* has no year. The public page sorts seasons by this value, newest first.
*/
seasonYear: number | null;
category: TournamentCategory;
divisionName: string;
/**
* The sub-cup (playoff bracket) that was won, e.g. "Copa Oro" / "Copa Plata",
* when the division splits its playoff into tiers. Null when the division
* crowns a single champion (a single bracket or a group-only division).
*/
cupName: string | null;
championTeam: IPodiumTeam;
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { GUID } from '@/modules/core/types/types';
import { IChampionHistory } from '@/modules/champion/type/champion.d';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { groupChampions } from './groupChampions';
const guid = (value: string) => value as GUID;
const entry = (overrides: Partial<IChampionHistory> = {}): IChampionHistory => ({
tournamentId: guid('tournament-1'),
tournamentName: 'Apertura 2025',
seasonName: 'Temporada 2025',
seasonYear: 2025,
category: TournamentCategory.Masculine,
divisionName: 'Zona A',
cupName: null,
championTeam: {
teamId: guid('team-1'),
teamName: 'Los Halcones',
logoUrl: null,
},
...overrides,
});
describe('groupChampions', () => {
it('returns an empty array for empty history', () => {
expect(groupChampions([])).toEqual([]);
});
it('orders seasons by year, newest first', () => {
const result = groupChampions([
entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }),
entry({ seasonName: 'Temporada 2026', seasonYear: 2026 }),
entry({ seasonName: 'Temporada 2024', seasonYear: 2024 }),
]);
expect(result.map(season => season.seasonName)).toEqual([
'Temporada 2026',
'Temporada 2025',
'Temporada 2024',
]);
expect(result.map(season => season.seasonYear)).toEqual([2026, 2025, 2024]);
});
it('sorts null-year seasons (including "Sin temporada") last', () => {
const result = groupChampions([
entry({ seasonName: null, seasonYear: null }),
entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }),
]);
expect(result.map(season => season.seasonName)).toEqual([
'Temporada 2025',
'Sin temporada',
]);
});
it('buckets null or empty seasons under "Sin temporada"', () => {
const result = groupChampions([
entry({ seasonName: null, seasonYear: null }),
entry({ seasonName: '', seasonYear: null }),
]);
expect(result).toHaveLength(1);
expect(result[0].seasonName).toBe('Sin temporada');
});
it('nests Season -> Tournament -> Division, carrying the category on the tournament', () => {
const result = groupChampions([
entry({
tournamentId: guid('t-masc'),
tournamentName: 'Apertura Masculino',
category: TournamentCategory.Masculine,
divisionName: 'Zona A',
}),
entry({
tournamentId: guid('t-fem'),
tournamentName: 'Apertura Femenino',
category: TournamentCategory.Feminine,
divisionName: 'Zona Única',
}),
entry({
tournamentId: guid('t-masc'),
tournamentName: 'Apertura Masculino',
category: TournamentCategory.Masculine,
divisionName: 'Zona B',
}),
]);
expect(result).toHaveLength(1);
const [season] = result;
expect(season.tournaments.map(t => t.tournamentName)).toEqual([
'Apertura Masculino',
'Apertura Femenino',
]);
const masc = season.tournaments.find(t => t.tournamentId === guid('t-masc'));
expect(masc?.category).toBe(TournamentCategory.Masculine);
expect(masc?.divisions.map(d => d.divisionName)).toEqual(['Zona A', 'Zona B']);
});
it('keeps every sub-cup champion of a division, in backend (tier) order', () => {
const result = groupChampions([
entry({ divisionName: 'Primera', cupName: 'Copa Oro', championTeam: {
teamId: guid('gold'), teamName: 'Oro FC', logoUrl: null,
} }),
entry({ divisionName: 'Primera', cupName: 'Copa Plata', championTeam: {
teamId: guid('silver'), teamName: 'Plata FC', logoUrl: null,
} }),
]);
const division = result[0].tournaments[0].divisions[0];
expect(division.divisionName).toBe('Primera');
expect(division.entries.map(e => e.cupName)).toEqual(['Copa Oro', 'Copa Plata']);
});
});
@@ -0,0 +1,99 @@
import { IChampionHistory } from '@/modules/champion/type/champion.d';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { GUID } from '@/modules/core/types/types';
/** A division bucket inside a tournament, holding its per-cup champions in order. */
export interface ChampionDivisionGroup {
divisionName: string;
entries: IChampionHistory[];
}
/** A tournament bucket inside a season, holding its divisions. Carries the
* tournament's category so the view can badge it. */
export interface ChampionTournamentGroup {
tournamentId: GUID;
tournamentName: string;
category: TournamentCategory;
divisions: ChampionDivisionGroup[];
}
/** A season bucket holding its tournaments. */
export interface ChampionSeasonGroup {
seasonName: string;
/** Calendar year of the season; null for "Sin temporada" or a year-less season. */
seasonYear: number | null;
tournaments: ChampionTournamentGroup[];
}
/** Fallback label for tournaments not yet assigned to a season. */
const NO_SEASON_LABEL = 'Sin temporada';
/**
* Shapes the flat champion history into the public page's hierarchy:
* Season → Tournament → Division → per-cup champion entries. Seasons are
* ordered by year, newest first; a year-less season (including the single
* "Sin temporada" bucket for entries with a null/empty season) sorts last,
* with `seasonName` descending as the deterministic tiebreak. Within a season
* the tournament, division and per-cup order follows the backend's
* first-appearance order, so the within-division entries keep the backend's
* tier order (Copa Oro before Copa Plata). A tournament already implies its
* category, so category is carried on the tournament rather than used as a
* grouping level.
*/
export const groupChampions = (
history: IChampionHistory[]
): ChampionSeasonGroup[] => {
const seasonOrder: string[] = [];
const bySeason = new Map<string, ChampionSeasonGroup>();
history.forEach(entry => {
const seasonKey = entry.seasonName || NO_SEASON_LABEL;
let season = bySeason.get(seasonKey);
if (!season) {
season = {
seasonName: seasonKey,
seasonYear: entry.seasonName ? entry.seasonYear : null,
tournaments: [],
};
bySeason.set(seasonKey, season);
seasonOrder.push(seasonKey);
}
let tournament = season.tournaments.find(
t => t.tournamentId === entry.tournamentId
);
if (!tournament) {
tournament = {
tournamentId: entry.tournamentId,
tournamentName: entry.tournamentName,
category: entry.category,
divisions: [],
};
season.tournaments.push(tournament);
}
let division = tournament.divisions.find(
d => d.divisionName === entry.divisionName
);
if (!division) {
division = { divisionName: entry.divisionName, entries: [] };
tournament.divisions.push(division);
}
division.entries.push(entry);
});
return seasonOrder
.map(seasonName => bySeason.get(seasonName)!)
.sort((a, b) => {
// Year desc; a null year always sorts after a real one.
if (a.seasonYear !== b.seasonYear) {
if (a.seasonYear === null) return 1;
if (b.seasonYear === null) return -1;
return b.seasonYear - a.seasonYear;
}
// Same year (or both null): name desc, for a deterministic order.
return b.seasonName.localeCompare(a.seasonName);
});
};
@@ -0,0 +1,219 @@
import { AxiosResponse } from 'axios';
import {
createContext,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GUID } from '@/modules/core/types/types';
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
import { clubService } from '@/modules/club/service/club.service';
import { clubKeys } from '@/modules/club/queryKeys';
import {
IClubContextProps,
IClubHistoryResponse,
IClubSummaryResponse,
IRosterCopyRequest,
IRosterCopyResult,
} from '@/modules/club/type/club.d';
export const ClubContext = createContext<IClubContextProps | undefined>(
undefined
);
export const ClubProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [club, setClub] = useState<IClubHistoryResponse | null>(null);
const [allClubs, setAllClubs] = useState<IClubSummaryResponse[]>([]);
const queryClient = useQueryClient();
const handleUnknownError = useUnknownErrorHandler();
const copyRosterMutation = useMutation({
mutationFn: ({
targetTeamId,
request,
}: {
targetTeamId: GUID;
request: IRosterCopyRequest;
}) => clubService.copyRoster(targetTeamId, request),
});
const linkClubParentMutation = useMutation({
mutationFn: ({
childClubId,
parentClubId,
}: {
childClubId: GUID;
parentClubId: GUID;
}) => clubService.linkClubParent(childClubId, parentClubId),
});
const unlinkClubParentMutation = useMutation({
mutationFn: (childClubId: GUID) => clubService.unlinkClubParent(childClubId),
});
const renameClubMutation = useMutation({
mutationFn: ({ clubId, name }: { clubId: GUID; name: string }) =>
clubService.renameClub(clubId, name),
});
const deleteClubMutation = useMutation({
mutationFn: (clubId: GUID) => clubService.deleteClub(clubId),
});
const getClubHistory = useCallback(
async (idOrSlug: string): Promise<IClubHistoryResponse | void> => {
try {
const res: AxiosResponse<IClubHistoryResponse> =
await queryClient.fetchQuery({
queryKey: clubKeys.history(idOrSlug),
queryFn: async () => await clubService.getClubHistory(idOrSlug),
});
if (res) {
setClub(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[queryClient, handleUnknownError]
);
const copyRoster = useCallback(
async (
targetTeamId: GUID,
request: IRosterCopyRequest
): Promise<IRosterCopyResult | void> => {
try {
const res: AxiosResponse<IRosterCopyResult> =
await copyRosterMutation.mutateAsync({ targetTeamId, request });
return res?.data;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[copyRosterMutation, handleUnknownError]
);
const getAllClubs = useCallback(async (): Promise<
IClubSummaryResponse[] | void
> => {
try {
const res: AxiosResponse<IClubSummaryResponse[]> =
await queryClient.fetchQuery({
queryKey: clubKeys.all(),
queryFn: async () => await clubService.getAllClubs(),
});
if (res) {
setAllClubs(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
}, [queryClient, handleUnknownError]);
const linkClubParent = useCallback(
async (
childClubId: GUID,
parentClubId: GUID
): Promise<IClubHistoryResponse | void> => {
try {
const res: AxiosResponse<IClubHistoryResponse> =
await linkClubParentMutation.mutateAsync({
childClubId,
parentClubId,
});
if (res) {
setClub(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[linkClubParentMutation, handleUnknownError]
);
const unlinkClubParent = useCallback(
async (childClubId: GUID): Promise<IClubHistoryResponse | void> => {
try {
const res: AxiosResponse<IClubHistoryResponse> =
await unlinkClubParentMutation.mutateAsync(childClubId);
if (res) {
setClub(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[unlinkClubParentMutation, handleUnknownError]
);
const renameClub = useCallback(
async (clubId: GUID, name: string): Promise<IClubHistoryResponse | void> => {
try {
const res: AxiosResponse<IClubHistoryResponse> =
await renameClubMutation.mutateAsync({ clubId, name });
if (res) {
setClub(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[renameClubMutation, handleUnknownError]
);
const deleteClub = useCallback(
async (clubId: GUID): Promise<boolean> => {
try {
await deleteClubMutation.mutateAsync(clubId);
setAllClubs(prev => prev.filter(candidate => candidate.id !== clubId));
return true;
} catch (error: unknown) {
handleUnknownError(error);
return false;
}
},
[deleteClubMutation, handleUnknownError]
);
const container: IClubContextProps = useMemo(
() => ({
club,
getClubHistory,
copyRoster,
allClubs,
getAllClubs,
linkClubParent,
unlinkClubParent,
renameClub,
deleteClub,
}),
[
club,
getClubHistory,
copyRoster,
allClubs,
getAllClubs,
linkClubParent,
unlinkClubParent,
renameClub,
deleteClub,
]
);
return (
<ClubContext.Provider value={container}>{children}</ClubContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { ClubContext } from '@/modules/club/context/club.context';
export const useClub = () => {
const context = useContext(ClubContext);
if (!context) {
throw new Error('useClub must be used within a ClubProvider');
}
return context;
};
@@ -0,0 +1,4 @@
export const clubKeys = {
history: (idOrSlug: string) => ['club', 'history', idOrSlug] as const,
all: () => ['club', 'all'] as const,
};
@@ -0,0 +1,87 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { GUID } from '@/modules/core/types/types';
import { sendDelete, sendGet, sendPost, sendPut } from '@/modules/core/utils/axiosUtils';
import {
IClubHistoryResponse,
IClubSummaryResponse,
IRosterCopyRequest,
IRosterCopyResult,
} from '@/modules/club/type/club.d';
/**
* Service for the stable cross-season club identity (HU-99) and roster
* cloning between seasons (HU-53).
*/
export const clubService = {
/**
* Retrieves a club and its trajectory across seasons.
* @param {string} idOrSlug - The club's GUID id or its slug.
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
*/
getClubHistory: async (
idOrSlug: string
): Promise<AxiosResponse<IClubHistoryResponse>> =>
await sendGet(`${routes.clubs}/${idOrSlug}`),
/**
* Clones a roster from a previous season's team into the target team.
* @param {GUID} targetTeamId - The team to copy the roster into.
* @param {IRosterCopyRequest} request - The source team + season and target season.
* @returns {Promise<AxiosResponse<IRosterCopyResult>>} The server response.
*/
copyRoster: async (
targetTeamId: GUID,
request: IRosterCopyRequest
): Promise<AxiosResponse<IRosterCopyResult>> =>
await sendPost(`${routes.teams}/${targetTeamId}/roster/copy`, request),
/**
* Retrieves every club's stable identity summary.
* @returns {Promise<AxiosResponse<IClubSummaryResponse[]>>} The server response.
*/
getAllClubs: async (): Promise<AxiosResponse<IClubSummaryResponse[]>> =>
await sendGet(routes.clubs),
/**
* Links a club as a squad of a parent institution club.
* @param {GUID} childClubId - The squad club to link.
* @param {GUID} parentClubId - The institution club it becomes a squad of.
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
*/
linkClubParent: async (
childClubId: GUID,
parentClubId: GUID
): Promise<AxiosResponse<IClubHistoryResponse>> =>
await sendPut(`${routes.clubs}/${childClubId}/parent`, { parentClubId }),
/**
* Clears a club's parent institution link, if any.
* @param {GUID} childClubId - The club to unlink.
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
*/
unlinkClubParent: async (
childClubId: GUID
): Promise<AxiosResponse<IClubHistoryResponse>> =>
await sendDelete(`${routes.clubs}/${childClubId}/parent`),
/**
* Renames a club.
* @param {GUID} clubId - The club to rename.
* @param {string} name - The new display name.
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
*/
renameClub: async (
clubId: GUID,
name: string
): Promise<AxiosResponse<IClubHistoryResponse>> =>
await sendPut(`${routes.clubs}/${clubId}`, { name }),
/**
* Deletes a club.
* @param {GUID} clubId - The club to delete.
* @returns {Promise<AxiosResponse<void>>} The server response.
*/
deleteClub: async (clubId: GUID): Promise<AxiosResponse<void>> =>
await sendDelete(`${routes.clubs}/${clubId}`),
};
+162
View File
@@ -0,0 +1,162 @@
import { GUID } from '@/modules/core/types/types';
/**
* One season (tournament) a club's team was registered in.
* @interface IClubSeasonResponse
*/
export interface IClubSeasonResponse {
/** The tournament (season) id. */
tournamentId: GUID;
/** The tournament (season) display name, when available. */
tournamentName: string | null;
/**
* The tournament's start date (ISO string). Sort key only — the history
* table shows the tournament name, and rows are ordered newest-first by
* this value.
*/
startDate: string;
}
/**
* One per-season team belonging to a club, with the seasons it played.
* @interface IClubTeamSeasonResponse
*/
export interface IClubTeamSeasonResponse {
/** The per-season team id. */
teamId: GUID;
/** The team name for that season. */
name: string;
/** The team's URL-friendly slug. */
slug: string;
/** The three-letter code of the team. */
threeLetterCode: string;
/** The tournaments (seasons) this team was registered in. */
seasons: IClubSeasonResponse[];
}
/**
* A minimal club identity, used for pickers and for referencing a related
* club (parent institution / child squad) without its full season history.
* @interface IClubSummaryResponse
*/
export interface IClubSummaryResponse {
id: GUID;
name: string;
slug: string;
logoUrl: string | null;
}
/**
* A club and its trajectory across seasons (HU-99): the stable club identity
* plus every per-season team that belongs to it.
* @interface IClubHistoryResponse
*/
export interface IClubHistoryResponse {
/** The stable club id. */
id: GUID;
/** The club name. */
name: string;
/** The club's URL-friendly slug. */
slug: string;
/** The club logo URL, when available. */
logoUrl: string | null;
/** The per-season teams that make up this club's history. */
teams: IClubTeamSeasonResponse[];
/**
* The parent institution this club is a squad of, or null when this club
* has no parent linked.
*/
parentClub: IClubSummaryResponse | null;
/** Other squads linked to this club as their parent institution. */
childClubs: IClubSummaryResponse[];
}
/**
* Request body to clone a roster from a previous season's team (HU-53). The
* target team is taken from the route; this identifies the source team +
* season to copy from and the target season to copy into.
* @interface IRosterCopyRequest
*/
export interface IRosterCopyRequest {
/** The past-season team whose roster is the source. */
sourceTeamId: GUID;
/** The season (tournament) the source roster belongs to. */
sourceTournamentId: GUID;
/** The new season (tournament) the roster is cloned into. */
targetTournamentId: GUID;
}
/**
* Outcome of copying a roster into a new season (HU-53).
* @interface IRosterCopyResult
*/
export interface IRosterCopyResult {
/** New season registrations created on the target team. */
copiedCount: number;
/** Source players skipped because already registered to the target season. */
skippedCount: number;
}
/**
* Context properties for reading a club's cross-season history (HU-99) and
* importing a roster from a previous season (HU-53).
* @interface IClubContextProps
*/
export interface IClubContextProps {
/** The last-fetched club history, or null. */
club: IClubHistoryResponse | null;
/**
* Fetches a club and its per-season trajectory by id or slug.
* @param idOrSlug The club's GUID id or its slug.
* @returns A promise that resolves with the club history.
*/
getClubHistory(idOrSlug: string): Promise<IClubHistoryResponse | void>;
/**
* Clones a roster from a previous season's team into a target team.
* @param targetTeamId The team to copy the roster into.
* @param request The source team + season and the target season.
* @returns A promise that resolves with the copied/skipped counts.
*/
copyRoster(
targetTeamId: GUID,
request: IRosterCopyRequest
): Promise<IRosterCopyResult | void>;
/** Every club's stable identity summary, for the "link to parent club" picker. */
allClubs: IClubSummaryResponse[];
/** Fetches every club's stable identity summary. */
getAllClubs(): Promise<IClubSummaryResponse[] | void>;
/**
* Links a club as a squad of a parent institution club.
* @param childClubId The squad club to link.
* @param parentClubId The institution club it becomes a squad of.
*/
linkClubParent(
childClubId: GUID,
parentClubId: GUID
): Promise<IClubHistoryResponse | void>;
/**
* Clears a club's parent institution link, if any.
* @param childClubId The club to unlink.
*/
unlinkClubParent(childClubId: GUID): Promise<IClubHistoryResponse | void>;
/**
* Renames a club. The club's slug never changes, so its public URL stays
* stable.
* @param clubId The club to rename.
* @param name The new display name.
*/
renameClub(clubId: GUID, name: string): Promise<IClubHistoryResponse | void>;
/**
* Deletes a club, rejected while it still has teams or squad clubs linked to it.
* @param clubId The club to delete.
*/
deleteClub(clubId: GUID): Promise<boolean>;
}
@@ -0,0 +1,124 @@
/**
* Single source of truth for every frontend page path. Static paths are
* plain strings (used directly in Route definitions, nav links and
* navigate() calls). Dynamic paths expose both a `pattern` (with the
* :param placeholder, for Route definitions) and a `build` function (for
* navigate() calls with a real id).
*/
export const APP_ROUTES = {
home: '/',
login: '/login',
forgotPassword: '/auth/olvide-password',
activate: '/auth/activar',
passwordReset: '/auth/password-reset',
quienesSomos: '/quienes-somos',
fichaMedica: '/ficha-medica',
reglamento: '/reglamento',
forbidden: '/forbidden',
publicTeam: {
pattern: '/equipos/:teamId',
build: (teamId: string) => `/equipos/${teamId}`,
},
publicSanctions: '/sanciones',
publicChampions: '/campeones',
publicMatch: {
pattern: '/partidos/:matchId',
build: (matchId: string) => `/partidos/${matchId}`,
},
publicSeasons: '/temporadas',
publicSeason: {
pattern: '/temporadas/:seasonId',
build: (seasonId: string) => `/temporadas/${seasonId}`,
},
publicTournament: {
pattern: '/torneos/:tournamentId',
build: (tournamentId: string) => `/torneos/${tournamentId}`,
},
publicBlog: '/blog',
blogPost: {
pattern: '/blog/:idOrSlug',
build: (idOrSlug: string) => `/blog/${idOrSlug}`,
},
panel: '/panel',
panelPlayers: '/panel/jugadores',
panelPlayer: {
pattern: '/panel/jugadores/:playerId',
build: (playerId: string) => `/panel/jugadores/${playerId}`,
},
panelTeamDetail: {
pattern: '/panel/equipos/:teamId',
build: (teamId: string) => `/panel/equipos/${teamId}`,
},
panelTournamentDetail: {
pattern: '/panel/torneos/:tournamentId',
build: (tournamentId: string) => `/panel/torneos/${tournamentId}`,
},
panelTournamentEdit: {
pattern: '/panel/torneos/:tournamentId/editar',
build: (tournamentId: string) => `/panel/torneos/${tournamentId}/editar`,
},
panelTeams: '/panel/equipos',
panelClub: {
pattern: '/panel/clubes/:idOrSlug',
build: (idOrSlug: string) => `/panel/clubes/${idOrSlug}`,
},
panelSanctions: '/panel/sanciones',
panelSanction: {
pattern: '/panel/sanciones/:playerSanctionId',
build: (playerSanctionId: string) => `/panel/sanciones/${playerSanctionId}`,
},
panelSanctionEdit: {
pattern: '/panel/sanciones/editar/:playerSanctionId',
build: (playerSanctionId: string) =>
`/panel/sanciones/editar/${playerSanctionId}`,
},
panelVenues: '/panel/canchas',
panelVenue: {
pattern: '/panel/canchas/:venueId',
build: (venueId: string) => `/panel/canchas/${venueId}`,
},
panelSeasons: '/panel/temporadas',
panelSeason: {
pattern: '/panel/temporadas/:seasonId',
build: (seasonId: string) => `/panel/temporadas/${seasonId}`,
},
panelTournamentWizard: '/panel/torneos/asistente',
panelDivisionCreate: '/panel/divisiones/crear',
panelDivisionEdit: {
pattern: '/panel/divisiones/:divisionId/editar',
build: (divisionId: string) => `/panel/divisiones/${divisionId}/editar`,
},
panelDivision: {
pattern: '/panel/divisiones/:divisionId',
build: (divisionId: string) => `/panel/divisiones/${divisionId}`,
},
panelMatch: {
pattern: '/panel/partidos/:matchId',
build: (matchId: string) => `/panel/partidos/${matchId}`,
},
panelBlog: '/panel/blog',
panelBlogCreate: '/panel/blog/crear',
panelBlogEdit: {
pattern: '/panel/blog/:blogPostId/editar',
build: (blogPostId: string) => `/panel/blog/${blogPostId}/editar`,
},
panelUsers: '/panel/usuarios',
panelUserCreate: '/panel/usuarios/crear',
panelUserInvite: '/panel/usuarios/invitar',
panelUserEdit: {
pattern: '/panel/usuarios/:userId/editar',
build: (userId: string) => `/panel/usuarios/${userId}/editar`,
},
panelUser: {
pattern: '/panel/usuarios/:userId',
build: (userId: string) => `/panel/usuarios/${userId}`,
},
panelSettings: '/panel/configuracion',
panelChangePassword: '/panel/configuracion/cambiar-password',
panelEditProfile: '/panel/configuracion/editar-perfil',
panelStatistics: '/panel/estadisticas',
panelAuditLogs: '/panel/auditoria',
panelDataAdministration: '/panel/administracion-datos',
} as const;
@@ -0,0 +1,42 @@
export const COOKIE_SIGNIN_TOKEN = 'Club12_SignInToken';
export const SUCCESS_MESSAGES = {
LOGIN_SUCCESS: 'Sesión iniciada correctamente',
};
export const ERROR_MESSAGES = {
GENERIC_ERROR: 'Ocurrió un error. Por favor, intentá nuevamente.',
NETWORK_ERROR:
'No se pudo conectar con el servidor. Verificá tu conexión e intentá nuevamente.',
SERVER_UNAVAILABLE:
'El servidor no está disponible en este momento. Por favor, intentá nuevamente en unos minutos.',
LOGIN_FAILED: 'Usuario o contraseña incorrectos',
};
export const EXPIRATION_TIME = {
MS_IN_HOUR: 3600 * 1000,
MS_IN_MINUTE: 60 * 1000,
MS_IN_SECOND: 1000,
};
export const JWT = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
EXPIRES_IN: 'expiresIn',
};
export const FILTERS_DEBOUNCE_DELAY_MS = 500;
export const FILTERS_DEBOUNCE_DELAY_LONG_MS = 1000;
export const PUBLIC_SEARCH_DEBOUNCE_DELAY_MS = 600;
/**
* Applied to tabbed content areas (tournament/division tabs) so switching
* between a short tab (e.g. Información) and a long one (e.g. Partidos)
* doesn't visibly jump the page height and the footer position with it.
*/
export const TAB_CONTENT_MIN_HEIGHT = 400;
export const USERNAME_LENGTH = {
Min: 3,
Max: 50,
} as const;
@@ -0,0 +1,27 @@
import { esES } from '@mui/x-data-grid/locales';
/**
* Spanish (es-ES) localeText for the MUI X DataGrid — pager ("Filas por
* página", "de"), filters, column menu, etc.
*
* The theme already merges `esES` into
* `theme.components.MuiDataGrid.defaultProps.localeText`, which covers grids
* that do NOT pass their own `localeText`. But MUI's `resolveProps` only fills
* a prop from `defaultProps` when the component leaves it `undefined`: a page
* that passes `localeText={{ noRowsLabel }}` shallow-REPLACES the theme's
* Spanish localeText, so the footer falls back to the English defaults.
*
* Any DataGrid that needs a custom empty-rows message must therefore spread
* this constant into its `localeText` prop. `dataGridLocaleText(noRowsLabel)`
* does exactly that in a single call.
*/
export const DATA_GRID_ES_LOCALE_TEXT =
esES.components.MuiDataGrid.defaultProps.localeText;
/** Spanish DataGrid localeText merged with a page-specific empty-rows label. */
export const dataGridLocaleText = (
noRowsLabel: string
): typeof DATA_GRID_ES_LOCALE_TEXT => ({
...DATA_GRID_ES_LOCALE_TEXT,
noRowsLabel,
});
@@ -0,0 +1,18 @@
/**
* Named HTTP status codes used to interpret API responses, instead of
* comparing against raw numeric literals throughout the app.
*/
export const HttpStatus = {
Ok: 200,
Created: 201,
NoContent: 204,
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
NotFound: 404,
Conflict: 409,
InternalServerError: 500,
BadGateway: 502,
ServiceUnavailable: 503,
GatewayTimeout: 504,
} as const;
@@ -0,0 +1,4 @@
export enum Order {
ASC = 0,
DESC = 1,
}
@@ -0,0 +1,23 @@
import { Filtered } from '@/modules/core/types/types';
export const TABLE_ROWS_PER_PAGE = 10;
export const TABLE_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
/**
* Page size used when fetching the full list of tournaments/divisions to
* populate a filter dropdown, effectively treating the fetch as "get all".
*/
export const FILTER_OPTIONS_PAGE_SIZE = 300;
/**
* Page size used on public (unauthenticated) listing pages that fetch
* effectively all items in a single request.
*/
export const PUBLIC_LISTING_PAGE_SIZE = 100;
export const withTablePageSize = <T extends Filtered>(
filter: T
): T & { pageSize: number } => ({
...filter,
pageSize: filter.pageSize ?? TABLE_ROWS_PER_PAGE,
});
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import routes from './routes';
describe('routes', () => {
it('tokenInvalido resolves to the invalid-token redirect path', () => {
expect(routes.tokenInvalido).toBe('/token-invalido');
});
it('apiUrl is the relative same-origin path /api (no hardcoded host)', () => {
expect(routes.apiUrl).toBe('/api');
});
});
@@ -0,0 +1,32 @@
const routes = {
apiUrl: '/api',
auditLogs: 'audit-logs',
backups: 'backups',
clubs: 'clubs',
blogposts: 'blogposts',
champions: 'champions',
maintenance: 'maintenance',
dataMaintenance: 'data-maintenance',
divisions: 'divisions',
stages: 'stages',
matches: 'matches',
matchSeries: 'match-series',
medicalRecords: 'medical-records',
players: 'players',
seasons: 'seasons',
scorer: 'scorer',
playerSanctions: 'player-sanctions',
pointDeductions: 'point-deductions',
playerStatistics: 'player-statistics',
statistics: 'statistics',
staff: 'staff',
teams: 'teams',
tournaments: 'tournaments',
users: 'users',
auth: 'auth',
venues: 'venues',
tokenInvalido: '/token-invalido',
};
export default routes;
@@ -0,0 +1,34 @@
/**
* The lifecycle status of a match (HU-69/HU-73). Mirrors the backend
* `Domain.Enums.MatchStatus` and is serialized as a string on the match
* response DTOs.
* @enum MatchStatus
*/
export enum MatchStatus {
/**
* The match has a fixture but no result loaded yet.
*/
Scheduled = 'Scheduled',
/**
* A normal result was loaded (HU-69); the match has a winner.
*/
Played = 'Played',
/**
* The match was suspended (HU-68/HU-73) and awaits rescheduling.
*/
Suspended = 'Suspended',
/**
* A walkover was applied (HU-73): the present team was awarded the
* regulation default result. Distinguishable from a normal `Played`.
*/
WalkOver = 'WalkOver',
/**
* The match will never be played: its tournament was canceled, or
* force-closed as finished while the match was still pending.
*/
Canceled = 'Canceled',
}
@@ -0,0 +1,17 @@
/**
* The types of matches that can exist (Regular or Playoff).
* @enum MatchType
*/
export enum MatchType {
/**
* A regular match in the tournament.
* @type {string}
*/
Regular = 'Regular',
/**
* A playoff match in the tournament.
* @type {string}
*/
Playoff = 'Playoff',
}
@@ -0,0 +1,29 @@
/**
* The medical-record / eligibility status of a player's season registration
* (HU-57). Mirrors the backend `Domain.Enums.MedicalRecordStatus` and is
* serialized as a string on the medical-record and roster response DTOs.
*
* The status is scoped per player + team + tournament, so being `Approved`
* in one season never carries over to another (HU-59). A player is
* "habilitado" only when the record is {@link Approved}.
* @enum MedicalRecordStatus
*/
export enum MedicalRecordStatus {
/**
* No medical record uploaded yet, or uploaded but not reviewed. The player
* is NOT habilitado.
*/
Pending = 'Pending',
/**
* The owner/admin reviewed and approved the record (HU-58): the player is
* habilitado for that team and tournament (HU-57).
*/
Approved = 'Approved',
/**
* The owner/admin rejected the record (HU-58), usually with a reason. The
* player is NOT habilitado.
*/
Rejected = 'Rejected',
}
@@ -0,0 +1,22 @@
/**
* Competitive category (gender) of a tournament (HU-48). The feminine
* competition is, by club rule, a SEPARATE tournament: a single tournament can
* never mix feminine and masculine divisions. The category lives on the
* tournament and every one of its divisions must share it.
*
* Values mirror the backend `Domain.Enums.TournamentCategory` names exactly
* (the API serializes enums as strings via JsonStringEnumConverter).
*/
export const TournamentCategory = {
Masculine: 'Masculine',
Feminine: 'Feminine',
} as const;
export type TournamentCategory =
(typeof TournamentCategory)[keyof typeof TournamentCategory];
/** Spanish display labels for each category, shared by the wizard and views. */
export const TOURNAMENT_CATEGORY_LABELS: Record<TournamentCategory, string> = {
[TournamentCategory.Masculine]: 'Masculino',
[TournamentCategory.Feminine]: 'Femenino',
};
@@ -0,0 +1,11 @@
export const TournamentStatus = {
Scheduled: 'Scheduled',
OpenForRegistration: 'OpenForRegistration',
RegistrationClosed: 'RegistrationClosed',
Ongoing: 'Ongoing',
Finished: 'Finished',
Canceled: 'Canceled',
} as const;
export type TournamentStatus =
(typeof TournamentStatus)[keyof typeof TournamentStatus];
@@ -0,0 +1,22 @@
export const UserRolesType = {
Admin: 'ADMIN',
Owner: 'OWNER',
Guest: 'GUEST',
} as const;
export type UserRolesType = (typeof UserRolesType)[keyof typeof UserRolesType];
/**
* Canonical Spanish display labels for every role. Co-located with the enum
* so every view (users list, create-user form, edit-user form) shows the
* same wording instead of each screen inventing its own.
*
* HU-05: the role model is reduced to Owner and Admin (plus the technical
* Guest). `Tournament Manager` and `Team Manager` were removed from the
* system and the UI.
*/
export const USER_ROLE_LABELS: Record<UserRolesType, string> = {
[UserRolesType.Admin]: 'Admin',
[UserRolesType.Owner]: 'Owner',
[UserRolesType.Guest]: 'Invitado',
};
+43
View File
@@ -0,0 +1,43 @@
import { ReactNode } from 'react';
import { Order } from '@/modules/core/constants/order';
export interface ProviderProps {
children: ReactNode;
}
export interface GenericResponsePagination<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
}
export interface Filtered {
pageNumber?: number;
pageSize?: number;
orderBy?: string;
order?: Order;
}
/**
* Per-call options for a context GET method. `silent` suppresses the global
* blocking alert on failure so a public page can render a quiet inline retry
* state instead — mutations (save/delete) never pass it and keep their alerts.
* `force` skips the context's "already have it in the local list" cache hit
* and re-fetches from the server — needed right after a mutation that
* changes data nested inside the cached item (e.g. deleting a tournament
* from within a season), which the cache hit would otherwise mask.
*/
export interface FetchOptions {
silent?: boolean;
force?: boolean;
}
export type RequestProps = {
method: string;
resource: string;
configOverride?: object;
body?: unknown;
query?: object;
};
export type GUID = `${string}-${string}-${string}-${string}-${string}`;
@@ -0,0 +1,174 @@
import { AxiosError } from 'axios';
import axios from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sendDelete, sendGet, sendPost } from './axiosUtils';
import { getActiveRequestCount } from './requestActivity';
const originalLocation = window.location;
const mockAssign = (): ReturnType<typeof vi.fn> => {
const assignSpy = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, assign: assignSpy },
});
return assignSpy;
};
vi.mock('axios', async importOriginal => {
const actual = await importOriginal<typeof import('axios')>();
return {
...actual,
default: {
...actual.default,
request: vi.fn(),
},
};
});
const buildUnauthorizedError = (hasAuthHeader: boolean): AxiosError =>
({
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed with status code 401',
config: {
headers: hasAuthHeader ? { Authorization: 'Bearer expired-token' } : {},
},
response: {
status: 401,
data: {},
statusText: 'Unauthorized',
headers: {},
config: { headers: {} },
},
toJSON: () => ({}),
}) as unknown as AxiosError;
describe('axiosUtils invalid-token redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('redirects to /token-invalido when a 401 error carries an Authorization header', async () => {
const assignSpy = mockAssign();
vi.mocked(axios.request).mockRejectedValueOnce(
buildUnauthorizedError(true)
);
await expect(sendDelete('divisions/123')).rejects.toBeTruthy();
expect(assignSpy).toHaveBeenCalledWith('/token-invalido');
});
it('does NOT redirect when a 401 error carries no Authorization header (and is not a refresh-token request)', async () => {
const assignSpy = mockAssign();
vi.mocked(axios.request).mockRejectedValueOnce(
buildUnauthorizedError(false)
);
await expect(sendDelete('divisions/123')).rejects.toBeTruthy();
expect(assignSpy).not.toHaveBeenCalled();
});
});
const buildNotFoundError = (): AxiosError =>
({
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed with status code 404',
config: { headers: {} },
response: {
status: 404,
data: {
title: 'Not Found: The specified resource could not be found.',
detail: 'Division with id 123 not found.',
status: 404,
},
statusText: 'Not Found',
headers: {},
config: { headers: {} },
},
toJSON: () => ({}),
}) as unknown as AxiosError;
describe('sendGet error pipeline', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('redirects to /token-invalido when a GET request gets a 401 with an Authorization header', async () => {
const assignSpy = mockAssign();
vi.mocked(axios.request).mockRejectedValueOnce(
buildUnauthorizedError(true)
);
await expect(sendGet('divisions/123')).rejects.toBeTruthy();
expect(assignSpy).toHaveBeenCalledWith('/token-invalido');
});
it('rejects with the same error shape as other verbs when a GET request gets a 404', async () => {
const notFoundError = buildNotFoundError();
vi.mocked(axios.request).mockRejectedValueOnce(notFoundError);
await expect(sendGet('divisions/123')).rejects.toBe(notFoundError);
});
});
describe('mutating-request activity tracking (drives GlobalLoadingOverlay)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('increments the active count while a POST is in flight, then releases it on success', async () => {
let resolveRequest: (value: unknown) => void = () => {};
const pending = new Promise(resolve => {
resolveRequest = resolve;
});
vi.mocked(axios.request).mockReturnValueOnce(pending as never);
expect(getActiveRequestCount()).toBe(0);
const requestPromise = sendPost('teams', { name: 'River' });
await Promise.resolve();
expect(getActiveRequestCount()).toBe(1);
resolveRequest({ data: {}, status: 201, statusText: 'Created', headers: {}, config: {} });
await requestPromise;
expect(getActiveRequestCount()).toBe(0);
});
it('releases the active count even when the mutating request fails', async () => {
vi.mocked(axios.request).mockRejectedValueOnce(buildNotFoundError());
await expect(sendPost('teams', { name: 'River' })).rejects.toBeTruthy();
expect(getActiveRequestCount()).toBe(0);
});
it('does NOT affect the active count for a GET (page data keeps its own skeleton loading)', async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: {},
status: 200,
statusText: 'OK',
headers: {},
config: {},
});
await sendGet('teams');
expect(getActiveRequestCount()).toBe(0);
});
});
@@ -0,0 +1,349 @@
import axios, { AxiosError, AxiosResponse } from 'axios';
import jsCookie from 'js-cookie';
import routes from '@/modules/core/constants/routes';
import {
ERROR_MESSAGES,
COOKIE_SIGNIN_TOKEN,
JWT,
} from '@/modules/core/constants/constants';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
import { beginRequest, endRequest } from '@/modules/core/utils/requestActivity';
const TOKEN_KEY: string = COOKIE_SIGNIN_TOKEN;
const INVALID_TOKEN_PATH = routes.tokenInvalido;
type headersContent = {
'Content-Type'?: string;
Authorization?: string;
};
type ConfigOverride = {
headers?: headersContent;
};
const statusCodeHandlers: Record<
number,
((response: AxiosResponse) => void)[]
> = {};
const hasAuthorizationHeader = (error: AxiosError): boolean => {
const headers = error.config?.headers as Record<string, unknown> | undefined;
const authorizationHeader = headers?.Authorization ?? headers?.authorization;
return Boolean(authorizationHeader);
};
const isRefreshTokenRequest = (error: AxiosError): boolean => {
const requestUrl = error.config?.url ?? '';
return requestUrl.includes('/auth/refresh-token');
};
const redirectToInvalidToken = (): void => {
if (typeof window === 'undefined') {
return;
}
if (window.location.pathname === INVALID_TOKEN_PATH) {
return;
}
unregisterToken();
localStorage.removeItem(JWT.REFRESH_TOKEN);
window.location.assign(INVALID_TOKEN_PATH);
};
const triggerStatusCodeHandlers = (response: AxiosResponse): void => {
const handlers = statusCodeHandlers[response.status];
if (!handlers?.length) {
return;
}
handlers.forEach(callback => {
callback(response);
});
};
const handleUnauthorizedToken = (error: AxiosError): void => {
const statusCode = error.response?.status;
if (statusCode !== HttpStatus.Unauthorized) {
return;
}
const shouldRedirect =
hasAuthorizationHeader(error) || isRefreshTokenRequest(error);
if (shouldRedirect) {
redirectToInvalidToken();
}
};
/**
* Checks if a token is set in cookies.
* @returns {boolean} True if the token exists, false otherwise.
*/
export const tokenIsSet = (): boolean => !!jsCookie.get(TOKEN_KEY);
/**
* Registers a token in cookies.
* @param {string} newToken - The token to store.
* @param {Date} expirationDate - The expiration date for the token.
*/
export const registerToken = (newToken: string, expirationDate: Date) => {
jsCookie.set(TOKEN_KEY, newToken, {
expires: expirationDate,
sameSite: 'lax',
path: '/',
});
};
/**
* Unregisters (removes) the token from cookies.
*/
export const unregisterToken = (): void => {
jsCookie.remove(TOKEN_KEY, {
path: '/',
});
};
/**
* Retrieves the currently registered token.
* @returns {string | undefined} The registered token, or undefined if none is set.
*/
export const getRegisteredToken = (): string | undefined =>
jsCookie.get(TOKEN_KEY);
/**
* Retrieves the default headers for requests.
* @returns {headersContent} The default headers.
*/
const getDefaultHeaders = (): headersContent => {
const headers: headersContent = {
'Content-Type': 'application/json; charset=utf-8',
};
if (tokenIsSet()) {
const token = jsCookie.get(TOKEN_KEY);
headers.Authorization = `Bearer ${token}`;
}
return headers;
};
/**
* Merges custom headers with default headers.
* @param {ConfigOverride} [configOverride] - The override configuration.
* @returns {headersContent} The resulting headers.
*/
const getHeaders = (configOverride?: ConfigOverride): headersContent => {
let headers: headersContent = getDefaultHeaders();
if (configOverride?.headers) {
headers = {
...headers,
...configOverride.headers,
};
}
return headers;
};
/**
* Builds the full API endpoint URL, including optional query parameters.
* @param {string} resource - The API resource.
* @param {object} [query] - The query parameters as an object.
* @returns {string} The encoded full endpoint URL.
*/
export const buildEndpoint = (resource: string, query?: object): string => {
const finalResource = `${routes.apiUrl}/${resource}`;
if (query) {
const queryParams = Object.entries(query)
.filter(
([, value]) => value !== undefined && value !== null && value !== ''
)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
)
.join('&');
if (queryParams.length > 0) {
return `${finalResource}?${queryParams}`;
}
}
return finalResource;
};
/**
* Sends an HTTP request.
* @param {string} method - HTTP method (GET, POST, PUT, DELETE).
* @param {string} resource - API resource.
* @param {object} [configOverride] - Request configuration overrides.
* @param {unknown | null} body - Request body data.
* @param {object} [query] - Query parameters.
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the response or undefined.
*/
const sendRequest = async <T>(
method: string,
resource: string,
configOverride: object = {},
body: unknown | null = null,
query?: object
): Promise<AxiosResponse<T>> => {
const headers = getHeaders(configOverride);
if (body instanceof FormData) {
delete headers['Content-Type'];
}
const url = buildEndpoint(resource, query);
// Mutations (loading/uploading/saving something) block the whole screen
// via GlobalLoadingOverlay; GETs keep their own skeleton-loading pattern.
const isMutation = method !== 'GET';
if (isMutation) {
beginRequest();
}
try {
const result: AxiosResponse<T> = await axios.request({
method,
url,
headers,
data: body,
});
return result;
} catch (error: unknown) {
throw throwError(error);
} finally {
if (isMutation) {
endRequest();
}
}
};
/**
* Throws an appropriate error based on its type.
* @param {unknown} error - The error object.
* @returns {AxiosError | Error} - The error object processed.
*/
const throwError = (error: unknown): AxiosError | Error => {
switch (true) {
case axios.isAxiosError(error): {
if (error.response) {
triggerStatusCodeHandlers(error.response);
}
handleUnauthorizedToken(error);
return error;
}
case error instanceof Error:
return new AxiosError(
error.message,
undefined,
undefined,
undefined,
undefined
);
default:
return new AxiosError(ERROR_MESSAGES.GENERIC_ERROR);
}
};
/**
* Sends a POST HTTP request.
* @param {string} resource - API resource.
* @param {unknown} [body] - Request body.
* @param {ConfigOverride} [configOverride] - Configuration overrides.
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
*/
export const sendPost = async <T>(
resource: string,
body?: unknown,
configOverride?: ConfigOverride
): Promise<AxiosResponse<T>> => {
return await sendRequest<T>('POST', resource, configOverride, body);
};
/**
* Sends a PUT HTTP request.
* @param {string} resource - API resource.
* @param {unknown} body - Request body.
* @param {ConfigOverride} [configOverride] - Configuration overrides.
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
*/
export const sendPut = async <T>(
resource: string,
body: unknown,
configOverride?: ConfigOverride
): Promise<AxiosResponse<T>> => {
return await sendRequest<T>('PUT', resource, configOverride, body);
};
/**
* Sends a GET HTTP request.
* @param {string} resource - API resource.
* @param {object} [query] - Query parameters.
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
*/
export const sendGet = async <T>(
resource: string,
query?: object
): Promise<AxiosResponse<T>> => {
return await sendRequest<T>('GET', resource, {}, null, query);
};
/**
* Sends a DELETE HTTP request.
* @param {string} resource - API resource.
* @param {ConfigOverride} [configOverride] - Configuration overrides.
* @returns {Promise<AxiosResponse<T>>} A promise that resolves when the resource is deleted.
*/
export const sendDelete = async <T>(
resource: string,
configOverride?: ConfigOverride,
body?: unknown
): Promise<AxiosResponse<T>> =>
await sendRequest<T>('DELETE', resource, configOverride, body);
/**
* Downloads a file from the server.
* @param {string} resource - API resource.
* @param {string} fileNameWithExtension - Name of the file to save locally.
*/
export const downloadfile = async (
resource: string,
fileNameWithExtension: string
) => {
const headers = getHeaders();
const url = buildEndpoint(resource);
const result = await axios.get(url, { headers, responseType: 'blob' });
const fileUrl = window.URL.createObjectURL(new Blob([result.data]));
const link = document.createElement('a');
link.href = fileUrl;
link.setAttribute('download', fileNameWithExtension);
document.body.appendChild(link);
link.click();
link.remove();
};
/**
* Registers a callback for a specific HTTP status code.
* @param {number} statusCode - HTTP status code.
* @param {() => unknown} callback - Function to execute when the status code is received.
*/
export const onStatusCode = (statusCode: number, callback: () => unknown) => {
if (statusCodeHandlers[statusCode]) {
statusCodeHandlers[statusCode].push(callback);
} else {
statusCodeHandlers[statusCode] = [callback];
}
};
/**
* Registers a callback for the 401 Unauthorized status code.
* @param {() => unknown} callback - Function to execute when a 401 status code is received.
*/
export const onUnauthorized = (callback: () => unknown) => {
onStatusCode(HttpStatus.Unauthorized, callback);
};
@@ -0,0 +1,52 @@
import { AxiosResponse } from 'axios';
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
interface ListItemWithId {
id: GUID;
}
/**
* Helper para realizar un fetch de una lista de la API y actualizar el estado condicionalmente.
* La llamada a la API siempre se realiza. El estado solo se actualiza si los datos han cambiado.
*
* @template T
* @template F
* @param {Object} options
* @param {(filter: F) => Promise<AxiosResponse<GenericResponsePagination<T>>>} apiCall
* @param {T[] | null} currentState
* @param {React.Dispatch<React.SetStateAction<T[] | null>>} setState
* @param {F} filter
* @returns {Promise<GenericResponsePagination<T> | void>}
*/
export async function fetchAndSetList<T extends ListItemWithId, F>(options: {
apiCall: (filter: F) => Promise<AxiosResponse<GenericResponsePagination<T>>>;
currentState: T[] | null;
setState: React.Dispatch<React.SetStateAction<T[] | null>>;
filter: F;
}): Promise<GenericResponsePagination<T> | void> {
const { apiCall, currentState, setState, filter } = options;
const res: AxiosResponse<GenericResponsePagination<T>> =
await apiCall(filter);
if (res && res.data) {
const newItems = res.data.items;
const currentIds = (currentState || [])
.map(item => item.id)
.sort()
.join(',');
const newIds = newItems
.map(item => item.id)
.sort()
.join(',');
if (newIds !== currentIds) {
setState(newItems);
}
return res.data;
}
return undefined;
}
@@ -0,0 +1,117 @@
import Swal from 'sweetalert2';
import { CANCEL_BUTTON_COLOR, getTheme } from '@/theme';
const theme = getTheme('dark');
const DIALOG_BACKGROUND = theme.palette.background.paper;
const DIALOG_TEXT_COLOR = theme.palette.text.primary;
// SweetAlert's container defaults to z-index 1060, which sits BELOW MUI's
// modals/dialogs (1300+). A confirm/notify fired while a MUI Dialog is open
// then renders behind it — its buttons unclickable and the awaited promise
// never resolving. Lifting the container above every MUI layer keeps alerts
// on top wherever they are triggered from.
const OVER_MUI_ZINDEX = '2000';
const liftAboveMuiModals = () => {
const container = Swal.getContainer();
if (container) {
container.style.zIndex = OVER_MUI_ZINDEX;
}
};
type NotifyIcon = 'success' | 'error' | 'warning' | 'info';
interface NotifyOptions {
title: string;
text?: string;
}
interface ConfirmActionOptions {
title: string;
text?: string;
icon?: 'warning' | 'question';
confirmButtonText?: string;
cancelButtonText?: string;
confirmButtonColor?: string;
cancelButtonColor?: string;
}
interface ConfirmDeleteOptions {
title: string;
text: string;
confirmButtonText?: string;
}
async function notify(icon: NotifyIcon, options: NotifyOptions): Promise<void> {
await Swal.fire({
title: options.title,
text: options.text,
icon,
confirmButtonColor: theme.palette.primary.main,
background: DIALOG_BACKGROUND,
color: DIALOG_TEXT_COLOR,
didOpen: liftAboveMuiModals,
});
}
/**
* Shows a standardized success dialog using the app theme colors.
* @param options - Title and optional text.
*/
export const notifySuccess = (options: NotifyOptions): Promise<void> =>
notify('success', options);
/**
* Shows a standardized error dialog using the app theme colors.
* @param options - Title and optional text.
*/
export const notifyError = (options: NotifyOptions): Promise<void> =>
notify('error', options);
/**
* Shows a standardized warning dialog using the app theme colors.
* Intended for validation messages that do not require confirmation.
* @param options - Title and optional text.
*/
export const notifyWarning = (options: NotifyOptions): Promise<void> =>
notify('warning', options);
/**
* Shows a standardized info dialog using the app theme colors.
* @param options - Title and optional text.
*/
export const notifyInfo = (options: NotifyOptions): Promise<void> =>
notify('info', options);
/**
* Shows a standardized confirm/cancel dialog. Button colors default to the
* app theme but can be overridden for semantic actions (e.g. a destructive
* action in red, an activation action in green).
* @param options - Title, text, icon and optional button text/colors.
* @returns `true` if the user confirmed the action, `false` otherwise.
*/
export async function confirmAction(options: ConfirmActionOptions): Promise<boolean> {
const result = await Swal.fire({
title: options.title,
text: options.text,
icon: options.icon ?? 'warning',
showCancelButton: true,
confirmButtonColor: options.confirmButtonColor ?? theme.palette.primary.main,
cancelButtonColor: options.cancelButtonColor ?? CANCEL_BUTTON_COLOR,
confirmButtonText: options.confirmButtonText ?? 'Confirmar',
cancelButtonText: options.cancelButtonText ?? 'Cancelar',
background: DIALOG_BACKGROUND,
color: DIALOG_TEXT_COLOR,
didOpen: liftAboveMuiModals,
});
return result.isConfirmed;
}
/**
* Shows a standardized delete-confirmation dialog using the app theme colors.
* @param options - Title, text and optional confirm button text.
* @returns `true` if the user confirmed the action, `false` otherwise.
*/
export const confirmDelete = (options: ConfirmDeleteOptions): Promise<boolean> =>
confirmAction({ ...options, confirmButtonText: options.confirmButtonText ?? 'Sí, eliminar' });
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildCsv, parseCsv } from '@/modules/core/utils/csv';
describe('buildCsv', () => {
it('emits the header row followed by data rows, CRLF-separated', () => {
const csv = buildCsv(
['#', 'Jugador', 'Puntos'],
[
[1, 'Ana Gómez', 12],
[2, 'Beto Ruiz', 9],
]
);
expect(csv).toBe(
'#,Jugador,Puntos\r\n1,Ana Gómez,12\r\n2,Beto Ruiz,9'
);
});
it('quotes cells containing commas, quotes or line breaks and doubles inner quotes', () => {
const csv = buildCsv(
['Equipo', 'Nota'],
[
['Club, 12', 'dijo "hola"'],
['Salto\nAlto', 'ok'],
]
);
const lines = csv.split('\r\n');
expect(lines[0]).toBe('Equipo,Nota');
expect(lines[1]).toBe('"Club, 12","dijo ""hola"""');
// A cell with a newline is wrapped in quotes, so the record spans two lines.
expect(csv).toContain('"Salto\nAlto",ok');
});
it('renders null and undefined cells as empty strings', () => {
const csv = buildCsv(['A', 'B', 'C'], [[null, undefined, 0]]);
expect(csv).toBe('A,B,C\r\n,,0');
});
});
describe('parseCsv', () => {
it('splits the header from data rows and trims header whitespace', () => {
const parsed = parseCsv('Nombre,Apellido\r\nAna,Gómez\r\nBeto,Ruiz');
expect(parsed.headers).toEqual(['Nombre', 'Apellido']);
expect(parsed.rows).toEqual([
['Ana', 'Gómez'],
['Beto', 'Ruiz'],
]);
});
it('unescapes quoted cells containing commas and doubled quotes', () => {
const parsed = parseCsv(
'Equipo,Nota\r\n"Club, 12","dijo ""hola"""'
);
expect(parsed.rows).toEqual([['Club, 12', 'dijo "hola"']]);
});
it('strips a leading UTF-8 BOM and skips blank lines', () => {
const parsed = parseCsv('A,B\r\n1,2\r\n\r\n3,4\r\n');
expect(parsed.headers).toEqual(['A', 'B']);
expect(parsed.rows).toEqual([
['1', '2'],
['3', '4'],
]);
});
it('returns empty headers/rows for empty input', () => {
expect(parseCsv('')).toEqual({ headers: [], rows: [] });
});
it('round-trips what buildCsv produces', () => {
const csv = buildCsv(
['Nombre', 'Nota'],
[['Ana, con coma', 'dijo "hola"']]
);
const parsed = parseCsv(csv);
expect(parsed.headers).toEqual(['Nombre', 'Nota']);
expect(parsed.rows).toEqual([['Ana, con coma', 'dijo "hola"']]);
});
});
@@ -0,0 +1,134 @@
/**
* Minimal, dependency-free CSV export helper (HU-89). Turns a header row plus a
* matrix of cells into an RFC-4180-style CSV string and triggers a client-side
* download, so standings, goleadores and fixtures can be shared outside the app
* without any server round-trip.
*/
export type CsvCellValue = string | number | boolean | null | undefined;
export type CsvRow = CsvCellValue[];
const CSV_DELIMITER = ',';
/** RFC-4180 records are separated by CRLF; Excel and Sheets both expect it. */
const CSV_LINE_BREAK = '\r\n';
/**
* UTF-8 BOM prepended to the download so spreadsheet apps (notably Excel on
* Windows) detect UTF-8 and render accented characters (á, ó, ñ) correctly.
*/
const UTF8_BOM = '';
/**
* Escapes a single CSV cell: renders null/undefined as empty, and wraps the
* value in double quotes (doubling any inner quote) whenever it contains a
* delimiter, a quote or a line break.
*/
const escapeCsvCell = (value: CsvCellValue): string => {
if (value === null || value === undefined) {
return '';
}
const text = String(value);
if (/[",\r\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
};
/**
* Builds a CSV string from a header row and data rows. Pure and
* side-effect-free so it can be unit-tested in isolation.
*/
export const buildCsv = (headers: string[], rows: CsvRow[]): string =>
[headers, ...rows]
.map(row => row.map(escapeCsvCell).join(CSV_DELIMITER))
.join(CSV_LINE_BREAK);
/**
* Builds a CSV from the given headers/rows and triggers a browser download of
* it as `<filename>.csv`.
*/
export const downloadCsv = (
filename: string,
headers: string[],
rows: CsvRow[]
): void => {
const csv = `${UTF8_BOM}${buildCsv(headers, rows)}`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename.toLowerCase().endsWith('.csv')
? filename
: `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export interface ParsedCsv {
headers: string[];
rows: string[][];
}
/** Splits one CSV record into cells, undoing {@link escapeCsvCell}'s
* double-quote escaping (a `""` inside a quoted cell is a literal `"`). */
const parseCsvLine = (line: string): string[] => {
const cells: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === CSV_DELIMITER) {
cells.push(current);
current = '';
} else {
current += char;
}
}
cells.push(current);
return cells;
};
/**
* Parses CSV text — the counterpart to {@link buildCsv} — into a header row
* and data rows of raw string cells. Blank lines are skipped so a trailing
* newline (or one left over from editing in a spreadsheet app) doesn't turn
* into a spurious empty row.
*/
export const parseCsv = (text: string): ParsedCsv => {
const lines = text
.replace(/^\uFEFF/, '')
.split(/\r\n|\n/)
.filter(line => line.trim() !== '');
if (lines.length === 0) {
return { headers: [], rows: [] };
}
const [headerLine, ...dataLines] = lines;
return {
headers: parseCsvLine(headerLine).map(cell => cell.trim()),
rows: dataLines.map(parseCsvLine),
};
};
@@ -0,0 +1,5 @@
export function handleFields(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const fields = Object.fromEntries(new window.FormData(event.currentTarget));
return fields;
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
formatCalendarDate,
formatDateAr,
formatDateTimeAr,
formatTimeAr,
formatLongDateTimeAr,
toArDayKey,
formatArDayLabel,
} from './formatDate';
// Argentina (America/Argentina/Buenos_Aires) is UTC-3 year-round, so a UTC
// instant always renders three hours earlier. These assertions must hold
// regardless of the machine timezone the test runs on (HU-100).
describe('Argentina-time formatting helpers', () => {
it('formats a known UTC instant as AR date + time', () => {
expect(formatDateTimeAr('2026-08-16T17:30:00Z')).toBe('16/08/2026 14:30');
});
it('formats the date portion in AR time', () => {
expect(formatDateAr('2026-08-16T17:30:00Z')).toBe('16/08/2026');
});
it('formats the time portion in AR time', () => {
expect(formatTimeAr('2026-08-16T17:30:00Z')).toBe('14:30');
});
it('rolls back to the previous AR day for an early-UTC instant', () => {
// 02:00 UTC on Jan 1 is 23:00 the previous day in Buenos Aires.
expect(formatDateAr('2026-01-01T02:00:00Z')).toBe('31/12/2025');
expect(formatTimeAr('2026-01-01T02:00:00Z')).toBe('23:00');
expect(toArDayKey('2026-01-01T02:00:00Z')).toBe('2025-12-31');
});
it('produces a long Spanish AR date-time label', () => {
expect(formatLongDateTimeAr('2026-08-16T17:30:00Z')).toContain(
'16 de agosto de 2026 • 14:30'
);
});
it('labels a day key with a capitalized Spanish weekday', () => {
expect(formatArDayLabel('2026-08-16')).toContain('16 de agosto');
expect(formatArDayLabel('2026-08-16')[0]).toBe(
formatArDayLabel('2026-08-16')[0].toUpperCase()
);
});
it('returns placeholders for empty or invalid input', () => {
expect(formatDateAr('')).toBe('—');
expect(formatDateTimeAr(null)).toBe('—');
expect(formatDateAr('not-a-date')).toBe('—');
expect(toArDayKey('')).toBe('unknown');
});
});
// A pure calendar date (tournament start date, registration deadline, birth
// date) is submitted from an <input type="date"> as UTC midnight of the
// intended day ("2026-10-10" -> new Date() -> "2026-10-10T00:00:00.000Z").
// It has no real time-of-day, so — unlike a genuine instant — it must NOT be
// shifted into Argentina time on display: formatDateAr would roll UTC
// midnight back to 21:00 the previous day in Buenos Aires (UTC-3),
// displaying the wrong day for every viewer west of UTC.
describe('formatCalendarDate — pure date fields, no timezone shift', () => {
it('renders UTC midnight as the same calendar day, not the previous one', () => {
expect(formatCalendarDate('2026-10-10T00:00:00.000Z')).toBe('10/10/2026');
// formatDateAr on the same value demonstrates the bug this guards against.
expect(formatDateAr('2026-10-10T00:00:00.000Z')).toBe('09/10/2026');
});
it('returns a placeholder for empty or invalid input', () => {
expect(formatCalendarDate('')).toBe('—');
expect(formatCalendarDate(null)).toBe('—');
expect(formatCalendarDate('not-a-date')).toBe('—');
});
});
@@ -0,0 +1,176 @@
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import 'dayjs/locale/es';
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* Canonical display timezone for the whole app (HU-100). The backend stores
* and returns instants in UTC; every user-facing date/time is presented in
* Argentina time regardless of the viewer's own timezone.
*/
export const AR_TIMEZONE = 'America/Argentina/Buenos_Aires';
/** Parses a UTC value and shifts it to Argentina time. */
const toArDayjs = (value: Date | string): Dayjs =>
dayjs.utc(value).tz(AR_TIMEZONE);
/**
* Formats a UTC value as a short Argentina-time date, e.g. "16/08/2026".
* Returns "—" for empty or unparseable input.
*/
export function formatDateAr(value?: Date | string | null): string {
if (!value) return '—';
const parsed = toArDayjs(value);
return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—';
}
/**
* Formats a pure calendar date (no time-of-day meaning — a tournament's
* start date, a registration deadline, a birth date) as "16/08/2026".
*
* Unlike {@link formatDateAr}, this does NOT shift the value into Argentina
* time: a date-only field is submitted as UTC midnight of the intended day
* (`new Date("2026-10-10")` → `2026-10-10T00:00:00.000Z`) and stored as-is,
* so applying any timezone conversion on display — Argentina or otherwise —
* rolls it back to the previous day for any viewer west of UTC. Reading the
* value's UTC calendar-date components directly is the only correct way to
* round-trip a date-only value regardless of viewer timezone.
* Returns "—" for empty or unparseable input.
*/
export function formatCalendarDate(value?: Date | string | null): string {
if (!value) return '—';
const parsed = dayjs.utc(value);
return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—';
}
/**
* Formats a UTC value as a short Argentina-time date and time, e.g.
* "16/08/2026 14:30". Returns "—" for empty or unparseable input.
*/
export function formatDateTimeAr(value?: Date | string | null): string {
if (!value) return '—';
const parsed = toArDayjs(value);
return parsed.isValid() ? parsed.format('DD/MM/YYYY HH:mm') : '—';
}
/**
* Formats a UTC value as an Argentina-time clock time, e.g. "14:30".
* Returns "—" for empty or unparseable input.
*/
export function formatTimeAr(value?: Date | string | null): string {
if (!value) return '—';
const parsed = toArDayjs(value);
return parsed.isValid() ? parsed.format('HH:mm') : '—';
}
/**
* Formats a UTC value as a long Spanish Argentina-time date, with no
* time-of-day, e.g. "lunes, 16 de agosto de 2026". Returns "—" for
* empty/invalid input.
*/
export function formatLongDateAr(value?: Date | string | null): string {
if (!value) return '—';
const parsed = toArDayjs(value);
return parsed.isValid() ? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY') : '—';
}
/**
* Formats a UTC value as a long Spanish Argentina-time date and time, e.g.
* "lunes, 16 de agosto de 2026 • 14:30". Returns "—" for empty/invalid input.
*/
export function formatLongDateTimeAr(value?: Date | string | null): string {
if (!value) return '—';
const parsed = toArDayjs(value);
return parsed.isValid()
? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY • HH:mm')
: '—';
}
/**
* Argentina-time calendar-day key ("YYYY-MM-DD") for a UTC value, or
* "unknown" for empty/invalid input. Used to group items (e.g. fixtures) by
* their Argentina-time day so an instant near midnight lands on the day the
* user actually sees, not the UTC day.
*/
export function toArDayKey(value?: Date | string | null): string {
if (!value) return 'unknown';
const parsed = toArDayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : 'unknown';
}
/**
* Formats a "YYYY-MM-DD" day key (see toArDayKey) as a long, capitalized
* Spanish weekday + day + month label, e.g. "Jueves, 1 de enero".
*/
export function formatArDayLabel(dayKey: string): string {
const parsed = dayjs(dayKey);
if (!parsed.isValid()) return 'Fecha a confirmar';
const label = parsed.locale('es').format('dddd, D [de] MMMM');
return label.charAt(0).toUpperCase() + label.slice(1);
}
/**
* Converts a UTC date string to a formatted Argentina-time date string in
* Spanish, e.g. "lunes, 16 de agosto de 2026 • 14:30".
* @param dateString - The date string in UTC format.
*/
export function formatMatchDateToString(dateString: string): string {
return formatLongDateTimeAr(dateString);
}
/**
* Converts a UTC date string to a local Date object.
* @param dateString - The date string in UTC format.
* @returns A JavaScript Date object in the local timezone.
*/
export function parseUTCToLocalDate(dateString: string): Date {
if (!dateString) return new Date(NaN);
return dayjs.utc(dateString).local().toDate();
}
/**
* Converts a Date object (assumed UTC) to a local Date object.
* @param date - The Date object in UTC.
* @returns A JavaScript Date object in the local timezone.
*/
export function convertToLocalDate(date: Date): Date {
if (!date) return new Date(NaN);
return dayjs.utc(date).local().toDate();
}
/**
* Converts a Date to a string compatible with <input type="datetime-local"> in local time.
* @param date - The Date object (UTC or local)
* @returns string in "YYYY-MM-DDTHH:mm" format
*/
export function formatDateTimeInput(date: Date): string {
if (!date) return '';
return dayjs(date).local().format('YYYY-MM-DDTHH:mm');
}
/**
* Formats an ISO/UTC date string into the "YYYY-MM-DDTHH:mm" value an
* <input type="datetime-local"> needs, or "" for empty/unparseable input.
* Used to preload a datetime-local field (e.g. a match's date when editing it
* or issuing a sanction from that match).
*/
export function toDatetimeLocalValue(iso?: string | null): string {
if (!iso) return '';
const parsed = new Date(iso);
return Number.isNaN(parsed.getTime()) ? '' : formatDateTimeInput(parsed);
}
/**
* Compares whether a deadline date is after the current date.
* @param {Date} deadline - The deadline to compare.
* @returns {boolean} - 'true' if the deadline has not yet passed, 'false' if it has already passed.
*/
export const isDeadlineInTheFuture = (deadline: Date): boolean => {
const now = new Date();
return new Date(deadline) > now;
};
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { geocodeAddress } from './geocoding';
const mockFetch = (response: Partial<Response> & { jsonBody?: unknown }) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: response.ok ?? true,
json: () => Promise.resolve(response.jsonBody ?? []),
})
);
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('geocodeAddress', () => {
it('returns the first result\'s coordinates', async () => {
mockFetch({ jsonBody: [{ lat: '-34.6037', lon: '-58.3816' }] });
const result = await geocodeAddress('Av. Corrientes 1000, CABA');
expect(result).toEqual({ latitude: -34.6037, longitude: -58.3816 });
});
it('returns null for an empty address without calling the network', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = await geocodeAddress(' ');
expect(result).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('returns null when the search yields no results', async () => {
mockFetch({ jsonBody: [] });
const result = await geocodeAddress('an address that does not exist anywhere');
expect(result).toBeNull();
});
it('returns null when the request fails', async () => {
mockFetch({ ok: false });
const result = await geocodeAddress('some address');
expect(result).toBeNull();
});
it('returns null when the network throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const result = await geocodeAddress('some address');
expect(result).toBeNull();
});
});
@@ -0,0 +1,44 @@
/**
* Looks up an address's coordinates via OpenStreetMap's free Nominatim
* search API (no API key required). Callers must debounce their own calls
* (e.g. on a pause in typing) rather than firing on every keystroke, per
* Nominatim's usage policy (https://operations.osmfoundation.org/policies/nominatim/).
* @param address The free-text address to look up.
* @returns The first match's coordinates, or null if nothing was found or the
* lookup failed.
*/
export const geocodeAddress = async (
address: string
): Promise<{ latitude: number; longitude: number } | null> => {
const trimmed = address.trim();
if (!trimmed) {
return null;
}
try {
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(trimmed)}`;
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
const results: Array<{ lat: string; lon: string }> = await response.json();
const [first] = results;
if (!first) {
return null;
}
const latitude = Number(first.lat);
const longitude = Number(first.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return { latitude, longitude };
} catch {
return null;
}
};
@@ -0,0 +1,112 @@
import { AxiosError } from 'axios';
import axios from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sendGet } from '@/modules/core/utils/axiosUtils';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
import {
dismissMaintenanceBanner,
getMaintenanceBannerSnapshot,
subscribeMaintenanceBanner,
} from '@/modules/core/utils/maintenanceBanner';
vi.mock('axios', async importOriginal => {
const actual = await importOriginal<typeof import('axios')>();
return {
...actual,
default: {
...actual.default,
request: vi.fn(),
},
};
});
const build503Error = (): AxiosError =>
({
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed with status code 503',
config: { headers: {} },
response: {
status: HttpStatus.ServiceUnavailable,
data: { message: 'La base de datos está en mantenimiento.' },
statusText: 'Service Unavailable',
headers: {},
config: { headers: {} },
},
toJSON: () => ({}),
}) as unknown as AxiosError;
beforeEach(() => {
vi.clearAllMocks();
dismissMaintenanceBanner();
});
afterEach(() => {
dismissMaintenanceBanner();
});
describe('maintenance banner — registered against the axiosUtils handler registry', () => {
it('starts inactive', () => {
expect(getMaintenanceBannerSnapshot()).toBe(false);
});
it('flips active when any request receives a 503, via onStatusCode(HttpStatus.ServiceUnavailable, ...)', async () => {
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
await expect(sendGet('backups')).rejects.toBeTruthy();
expect(getMaintenanceBannerSnapshot()).toBe(true);
});
it('notifies subscribers when the banner flips active', async () => {
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
let notified = false;
const unsubscribe = subscribeMaintenanceBanner(() => {
notified = true;
});
await expect(sendGet('backups')).rejects.toBeTruthy();
expect(notified).toBe(true);
unsubscribe();
});
it('does not flip the banner for an unrelated status code (e.g. 404)', async () => {
const notFoundError = {
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed with status code 404',
config: { headers: {} },
response: {
status: HttpStatus.NotFound,
data: {},
statusText: 'Not Found',
headers: {},
config: { headers: {} },
},
toJSON: () => ({}),
} as unknown as AxiosError;
vi.mocked(axios.request).mockRejectedValueOnce(notFoundError);
await expect(sendGet('backups')).rejects.toBeTruthy();
expect(getMaintenanceBannerSnapshot()).toBe(false);
});
it('dismissMaintenanceBanner resets the banner to inactive and notifies subscribers', async () => {
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
await expect(sendGet('backups')).rejects.toBeTruthy();
expect(getMaintenanceBannerSnapshot()).toBe(true);
let notified = false;
const unsubscribe = subscribeMaintenanceBanner(() => {
notified = true;
});
dismissMaintenanceBanner();
expect(getMaintenanceBannerSnapshot()).toBe(false);
expect(notified).toBe(true);
unsubscribe();
});
});
@@ -0,0 +1,57 @@
import { onStatusCode } from '@/modules/core/utils/axiosUtils';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
/**
* Global "the database is in maintenance" banner state, flipped by any
* request that comes back `503 Service Unavailable` (the restore-in-progress
* gate from `MaintenanceModeMiddleware`). Deliberately a plain module-level
* subscribable store — not a React context — so it can be wired against
* `onStatusCode` at import time, the same already-supported extension point
* `onUnauthorized` uses, with no axios interceptor rewrite.
*/
type Listener = () => void;
let isActive = false;
const listeners = new Set<Listener>();
const notify = (): void => {
listeners.forEach(listener => listener());
};
/**
* Marks the maintenance banner active and notifies every subscriber.
*/
export const activateMaintenanceBanner = (): void => {
isActive = true;
notify();
};
/**
* Marks the maintenance banner inactive and notifies every subscriber.
* Used once maintenance mode is confirmed cleared (e.g. after a manual
* `DELETE /api/maintenance` escape hatch, or after the banner UI dismisses).
*/
export const dismissMaintenanceBanner = (): void => {
isActive = false;
notify();
};
/**
* Current banner state, suitable as a `useSyncExternalStore` snapshot.
* @returns {boolean} Whether the maintenance banner is currently active.
*/
export const getMaintenanceBannerSnapshot = (): boolean => isActive;
/**
* Subscribes to banner state changes.
* @param {Listener} listener - Called with no arguments whenever the banner flips.
* @returns {() => void} Unsubscribe function.
*/
export const subscribeMaintenanceBanner = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
onStatusCode(HttpStatus.ServiceUnavailable, () => {
activateMaintenanceBanner();
});
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
DEFAULT_PAGE_METADATA,
resetPageMetadata,
setPageMetadata,
toAbsoluteUrl,
} from '@/modules/core/utils/pageMetadata';
const metaContent = (attribute: 'property' | 'name', key: string) =>
document.head
.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`)
?.getAttribute('content');
const canonicalHref = () =>
document.head
.querySelector<HTMLLinkElement>('link[rel="canonical"]')
?.getAttribute('href');
describe('toAbsoluteUrl', () => {
it('returns undefined for an empty path', () => {
expect(toAbsoluteUrl(undefined, 'https://club12.com')).toBeUndefined();
expect(toAbsoluteUrl('', 'https://club12.com')).toBeUndefined();
});
it('leaves an already-absolute URL untouched', () => {
expect(toAbsoluteUrl('https://cdn/x.png', 'https://club12.com')).toBe(
'https://cdn/x.png'
);
expect(toAbsoluteUrl('http://cdn/x.png', 'https://club12.com')).toBe(
'http://cdn/x.png'
);
});
it('joins a root-relative path onto the origin', () => {
expect(toAbsoluteUrl('/assets/logo.png', 'https://club12.com')).toBe(
'https://club12.com/assets/logo.png'
);
});
it('joins a bare path onto the origin with a separator', () => {
expect(toAbsoluteUrl('assets/logo.png', 'https://club12.com')).toBe(
'https://club12.com/assets/logo.png'
);
});
it('returns the relative path unchanged when there is no origin', () => {
expect(toAbsoluteUrl('/assets/logo.png', '')).toBe('/assets/logo.png');
});
});
describe('pageMetadata', () => {
afterEach(() => {
document.head.querySelectorAll('meta').forEach(meta => meta.remove());
document.head
.querySelectorAll('link[rel="canonical"]')
.forEach(link => link.remove());
document.title = '';
});
it('writes Open Graph and Twitter tags for a post (HU-17)', () => {
setPageMetadata({
title: 'Gran final',
description: 'Resumen del partido',
image: 'https://cdn.club12/photo.png',
url: 'https://club12/blog/gran-final',
type: 'article',
});
expect(document.title).toBe('Gran final · Club 12');
expect(metaContent('property', 'og:title')).toBe('Gran final');
expect(metaContent('property', 'og:description')).toBe(
'Resumen del partido'
);
expect(metaContent('property', 'og:image')).toBe(
'https://cdn.club12/photo.png'
);
expect(metaContent('property', 'og:url')).toBe(
'https://club12/blog/gran-final'
);
expect(metaContent('property', 'og:type')).toBe('article');
expect(metaContent('name', 'twitter:card')).toBe('summary_large_image');
expect(metaContent('name', 'twitter:title')).toBe('Gran final');
expect(metaContent('name', 'twitter:image')).toBe(
'https://cdn.club12/photo.png'
);
});
it('falls back to a plain summary card when there is no image', () => {
setPageMetadata({ title: 'Sin imagen', description: 'Texto' });
expect(metaContent('name', 'twitter:card')).toBe('summary');
expect(metaContent('property', 'og:image')).toBeUndefined();
expect(metaContent('name', 'twitter:image')).toBeUndefined();
});
it('reset restores the site defaults', () => {
setPageMetadata({ title: 'Gran final', description: 'x' });
resetPageMetadata();
expect(document.title).toBe('Club 12');
expect(metaContent('property', 'og:title')).toBe(
DEFAULT_PAGE_METADATA.title
);
expect(metaContent('property', 'og:description')).toBe(
DEFAULT_PAGE_METADATA.description
);
});
it('writes a canonical link defaulting to the current origin + path', () => {
setPageMetadata({ title: 'Campeones' });
const canonical = canonicalHref();
expect(canonical).toBeDefined();
expect(canonical).toBe(
`${window.location.origin}${window.location.pathname}`
);
});
it('honours an explicit canonical url', () => {
setPageMetadata({ title: 'Post', url: 'https://club12.com/blog/x' });
expect(canonicalHref()).toBe('https://club12.com/blog/x');
});
});
@@ -0,0 +1,182 @@
import { useEffect } from 'react';
/**
* Per-page social/SEO metadata (HU-17). Because the app is a client-rendered
* SPA (no SSR), these tags are written into the document head at runtime. That
* covers on-page sharing widgets and crawlers that execute JavaScript; static
* scrapers that never run JS will still read the index.html defaults.
*/
export interface PageMetadata {
/** Document title / og:title / twitter:title. */
title?: string;
/** Meta description / og:description / twitter:description. */
description?: string;
/** Absolute image URL for og:image / twitter:image. */
image?: string;
/** Canonical URL for og:url. Defaults to the current location. */
url?: string;
/** og:type. Defaults to "website". */
type?: string;
}
const SITE_NAME = 'Club 12';
/**
* Default social-share image (SEO). A root-relative path lives in `public/`;
* it is resolved to an absolute URL against the current origin at runtime so
* scrapers get a fully-qualified `og:image`.
*/
export const DEFAULT_OG_IMAGE = '/assets/logo-club12.png';
/** The site-wide default metadata restored when a page unmounts (HU-17). */
export const DEFAULT_PAGE_METADATA: PageMetadata = {
title: SITE_NAME,
description:
'La liga de básquet amateur con más historia de la zona. Torneos, ' +
'resultados y estadísticas de todas las divisiones en un solo lugar.',
type: 'website',
image: DEFAULT_OG_IMAGE,
};
/**
* Resolves a possibly-relative asset/URL path into an absolute URL against the
* given origin. Absolute inputs (http/https) pass through unchanged; empty
* inputs yield `undefined`; when there is no origin (SSR/tests) the relative
* path is returned as-is. Pure — safe to unit test.
*/
export const toAbsoluteUrl = (
path: string | undefined,
origin: string
): string | undefined => {
if (!path) {
return undefined;
}
if (/^https?:\/\//i.test(path)) {
return path;
}
if (!origin) {
return path;
}
return path.startsWith('/') ? `${origin}${path}` : `${origin}/${path}`;
};
const upsertCanonical = (href?: string): void => {
if (typeof document === 'undefined') {
return;
}
const selector = 'link[rel="canonical"]';
let element = document.head.querySelector<HTMLLinkElement>(selector);
if (!href) {
element?.remove();
return;
}
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'canonical');
document.head.appendChild(element);
}
element.setAttribute('href', href);
};
const upsertMeta = (
attribute: 'property' | 'name',
key: string,
content?: string
): void => {
if (typeof document === 'undefined') {
return;
}
const selector = `meta[${attribute}="${key}"]`;
let element = document.head.querySelector<HTMLMetaElement>(selector);
if (!content) {
element?.remove();
return;
}
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
};
/**
* Writes the given Open Graph / Twitter Card metadata into the document head,
* merging over the site defaults (HU-17). Title and description always fall
* back to the defaults; image and url are only emitted when provided.
*
* @param metadata The page-specific overrides.
*/
export const setPageMetadata = (metadata: PageMetadata): void => {
if (typeof document === 'undefined') {
return;
}
const title = metadata.title ?? DEFAULT_PAGE_METADATA.title;
const description =
metadata.description ?? DEFAULT_PAGE_METADATA.description;
const type = metadata.type ?? DEFAULT_PAGE_METADATA.type;
const origin =
typeof window !== 'undefined' ? window.location.origin : '';
const pathname =
typeof window !== 'undefined' ? window.location.pathname : '';
// Canonical (and og:url) omit query/hash so tab/filter permutations of a page
// collapse to one indexable URL.
const canonical =
metadata.url ?? (origin ? `${origin}${pathname}` : undefined);
const image = toAbsoluteUrl(metadata.image, origin);
const documentTitle =
title && title !== SITE_NAME ? `${title} · ${SITE_NAME}` : SITE_NAME;
document.title = documentTitle;
upsertMeta('name', 'description', description);
upsertMeta('property', 'og:site_name', SITE_NAME);
upsertMeta('property', 'og:title', title);
upsertMeta('property', 'og:description', description);
upsertMeta('property', 'og:type', type);
upsertMeta('property', 'og:url', canonical);
upsertMeta('property', 'og:image', image);
upsertMeta('name', 'twitter:card', image ? 'summary_large_image' : 'summary');
upsertMeta('name', 'twitter:title', title);
upsertMeta('name', 'twitter:description', description);
upsertMeta('name', 'twitter:image', image);
upsertCanonical(canonical);
};
/** Restores the site-wide default metadata (HU-17). */
export const resetPageMetadata = (): void => {
setPageMetadata(DEFAULT_PAGE_METADATA);
};
/**
* React hook that applies per-page metadata on mount/update and restores the
* site defaults on unmount (HU-17). Pass a stable/serialisable metadata object.
*
* @param metadata The page-specific metadata to apply.
*/
export const usePageMetadata = (metadata: PageMetadata): void => {
const { title, description, image, url, type } = metadata;
useEffect(() => {
setPageMetadata({ title, description, image, url, type });
return () => {
resetPageMetadata();
};
}, [title, description, image, url, type]);
};
@@ -0,0 +1,31 @@
/**
* `@media print` isolation: hide every element on the page except the
* subtree tagged `[data-print="sheet"]`, and force that subtree visible even
* though it is `display:none` on screen. This hides all app chrome
* (nav/tabs/buttons) without needing to tag them individually. Shared by
* every print-only sheet (standings, goleadores, …) so they all get the
* exact same isolation behavior from one place. Import and render via
* `<GlobalStyles styles={printMediaStyles} />` in each sheet component —
* only the currently-mounted sheet's `[data-print="sheet"]` node matters, so
* it is safe for more than one sheet component to inject this at once.
*/
export const printMediaStyles = {
'@media print': {
'body *': { visibility: 'hidden' },
'[data-print="sheet"], [data-print="sheet"] *': { visibility: 'visible' },
'[data-print="sheet"]': {
display: 'block !important',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
},
'[data-print="hide"]': { display: 'none !important' },
thead: { display: 'table-header-group' },
tr: { breakInside: 'avoid' },
'*': {
printColorAdjust: 'exact',
WebkitPrintColorAdjust: 'exact',
},
},
};
@@ -0,0 +1,28 @@
import { AxiosError } from 'axios';
/**
* Discriminated outcome of a mutation that can fail with a user-facing reason
* (e.g. a 409 Conflict the backend returns with a Spanish message). Callers
* surface {@link errorMessage} inline instead of routing the failure through
* the global error handler.
*/
export type MutationResult =
| { success: true }
| { success: false; errorMessage: string };
/**
* Reads the ProblemDetails `detail` string from an Axios error response, when
* present. The backend returns the raw business message there for 4xx errors.
*
* @param error The error thrown by a request.
* @returns The `detail` message, or undefined when it is not an Axios error
* with a string detail.
*/
export const extractProblemDetail = (error: unknown): string | undefined => {
if (!(error instanceof AxiosError)) {
return undefined;
}
const data = error.response?.data as { detail?: unknown } | undefined;
return typeof data?.detail === 'string' ? data.detail : undefined;
};
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
beginRequest,
clearBlockingMessage,
endRequest,
getActiveRequestCount,
getBlockingMessage,
runWithBlockingMessage,
setBlockingMessage,
subscribeToRequestActivity,
} from './requestActivity';
describe('requestActivity', () => {
it('tracks nested begin/end calls and notifies subscribers with the running count', () => {
const seen: number[] = [];
const unsubscribe = subscribeToRequestActivity(count => seen.push(count));
beginRequest();
beginRequest();
endRequest();
endRequest();
expect(seen).toEqual([1, 2, 1, 0]);
expect(getActiveRequestCount()).toBe(0);
unsubscribe();
});
it('never goes negative when endRequest is called without a matching begin', () => {
endRequest();
expect(getActiveRequestCount()).toBe(0);
});
it('stops notifying a listener after it unsubscribes', () => {
const seen: number[] = [];
const unsubscribe = subscribeToRequestActivity(count => seen.push(count));
unsubscribe();
beginRequest();
endRequest();
expect(seen).toEqual([]);
});
});
describe('requestActivity — blocking message', () => {
it('has no message until one is set, and the newest set message wins', () => {
expect(getBlockingMessage()).toBeNull();
const first = setBlockingMessage('Restaurando…');
expect(getBlockingMessage()).toBe('Restaurando…');
const second = setBlockingMessage('Generando…');
expect(getBlockingMessage()).toBe('Generando…');
clearBlockingMessage(second);
expect(getBlockingMessage()).toBe('Restaurando…');
clearBlockingMessage(first);
expect(getBlockingMessage()).toBeNull();
});
it('tolerates clearing messages out of order', () => {
const a = setBlockingMessage('A');
const b = setBlockingMessage('B');
clearBlockingMessage(a);
expect(getBlockingMessage()).toBe('B');
clearBlockingMessage(b);
expect(getBlockingMessage()).toBeNull();
});
it('notifies subscribers when the message changes', () => {
let calls = 0;
const unsubscribe = subscribeToRequestActivity(() => {
calls += 1;
});
const id = setBlockingMessage('X');
clearBlockingMessage(id);
expect(calls).toBe(2);
unsubscribe();
});
it('runWithBlockingMessage shows the message around the operation and clears it even on throw', async () => {
await runWithBlockingMessage('Trabajando…', async () => {
expect(getBlockingMessage()).toBe('Trabajando…');
});
expect(getBlockingMessage()).toBeNull();
await expect(
runWithBlockingMessage('Fallando…', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(getBlockingMessage()).toBeNull();
});
});
@@ -0,0 +1,87 @@
type Listener = (activeCount: number) => void;
let activeCount = 0;
const listeners = new Set<Listener>();
// A LIFO stack of contextual overlay messages. Each is pushed with a unique id
// so it can be popped out of order (nested/overlapping operations); the newest
// one wins as the visible message. Empty stack => no message, just the spinner.
const messageStack: { id: number; text: string }[] = [];
let nextMessageId = 1;
const notify = (): void => {
listeners.forEach(listener => listener(activeCount));
};
/**
* A tiny external store tracking how many mutating HTTP requests (POST/PUT/
* DELETE — loading/uploading/saving something that can take a moment) are in
* flight right now. `axiosUtils.sendRequest` is the single choke point every
* request goes through, so it increments/decrements this instead of each
* screen tracking its own `submitting` flag — `GlobalLoadingOverlay`
* subscribes to it once, at the app root, so ANY save/upload blocks the
* whole screen with a spinner without every call site wiring it manually.
* GET requests are intentionally excluded: page data already has its own
* skeleton-loading convention, and blocking the screen on every background
* fetch would be a jarring regression (see the "no blocking modals for GETs"
* rule the public pages already follow).
*/
export const beginRequest = (): void => {
activeCount += 1;
notify();
};
export const endRequest = (): void => {
activeCount = Math.max(0, activeCount - 1);
notify();
};
export const getActiveRequestCount = (): number => activeCount;
/**
* Sets a contextual message on the global blocking overlay for the duration of
* a long operation (e.g. "Restaurando la base de datos. No cierres esta
* página…"). Returns an id to pass to {@link clearBlockingMessage}. Prefer
* {@link runWithBlockingMessage}, which pairs the two automatically.
*/
export const setBlockingMessage = (text: string): number => {
const id = nextMessageId++;
messageStack.push({ id, text });
notify();
return id;
};
export const clearBlockingMessage = (id: number): void => {
const index = messageStack.findIndex(entry => entry.id === id);
if (index !== -1) {
messageStack.splice(index, 1);
notify();
}
};
export const getBlockingMessage = (): string | null =>
messageStack.length > 0 ? messageStack[messageStack.length - 1].text : null;
/**
* Runs `operation` with `message` shown on the global blocking overlay, always
* clearing it afterwards (success or throw). The overlay is already visible for
* any mutating request in flight; this only adds the contextual text.
*/
export const runWithBlockingMessage = async <T>(
message: string,
operation: () => Promise<T>
): Promise<T> => {
const id = setBlockingMessage(message);
try {
return await operation();
} finally {
clearBlockingMessage(id);
}
};
export const subscribeToRequestActivity = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
@@ -0,0 +1,17 @@
import { GUID } from '@/modules/core/types/types';
export function upsertListById<T extends { id: GUID }>(
list: T[] | null | undefined,
item: T
): T[] {
const safeList = list ?? [];
const index = safeList.findIndex(x => x.id === item.id);
if (index !== -1) {
const newList = [...safeList];
newList[index] = item;
return newList;
} else {
return [...safeList, item];
}
}
@@ -0,0 +1,20 @@
import { StageType } from '@/modules/stage/type/stage';
/**
* Spanish translations for each StageType value.
* Using `satisfies` enforces exhaustiveness at compile time.
*/
const STAGE_TYPE_ES = {
[StageType.Group]: 'Fase de grupos',
[StageType.RoundOf16]: 'Octavos de final',
[StageType.QuarterFinal]: 'Cuartos de final',
[StageType.SemiFinal]: 'Semifinal',
[StageType.ThirdPlace]: 'Tercer puesto',
[StageType.Final]: 'Final',
} satisfies Record<StageType, string>;
/**
* Translates a StageType enum value into its Spanish equivalent.
*/
export const translateStageType = (stageType: StageType): string =>
STAGE_TYPE_ES[stageType];
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
formatArgentinePhone,
formatDocumentNumber,
isAtLeastMinimumPlayerAge,
isValidDocumentNumber,
isValidEmail,
isValidPhone,
} from './validators';
describe('isValidEmail', () => {
it.each([
'user@example.com',
'jugador.12@club.com.ar',
'a@b.co',
' spaced@example.com ',
])('accepts a valid email: %s', email => {
expect(isValidEmail(email)).toBe(true);
});
it.each([
'',
'plainaddress',
'missing@domain',
'@no-local.com',
'no-at-sign.com',
'spaces in@email.com',
'double@@example.com',
])('rejects an invalid email: %s', email => {
expect(isValidEmail(email)).toBe(false);
});
});
describe('isValidPhone', () => {
it.each(['1123456789', '11 2345-6789', '(11) 2345 6789', '3431234567'])(
'accepts a 10-digit national number: %s',
phone => {
expect(isValidPhone(phone)).toBe(true);
}
);
it.each([
['', 'empty'],
['123', 'too few digits'],
['1234567', '7 digits, below the 10-digit national length'],
['01123456789', '11 digits — the 0 long-distance prefix is not accepted'],
['91123456789', '11 digits — the 9 mobile marker is not accepted'],
['+54 11 2345-6789', '12 digits — the +54 country code is not accepted'],
['+54 9 11 2345-6789', '13 digits — +54 9 is not accepted'],
['abc1234567', 'letters not allowed'],
['11 2345 6789 ext.4', 'letters not allowed'],
])('rejects an invalid phone: %s (%s)', phone => {
expect(isValidPhone(phone)).toBe(false);
});
it('counts only digits, ignoring separators', () => {
expect(isValidPhone('(11) 1234-5678')).toBe(true);
expect(isValidPhone('1-2-3-4-5-6-7')).toBe(false);
});
});
describe('isValidDocumentNumber', () => {
it.each(['30111222', '1234567', '999999999999999'])(
'accepts a digits-only document number: %s',
value => {
expect(isValidDocumentNumber(value)).toBe(true);
}
);
it.each([
'', // empty
'd23', // letters
'12345', // too short (5 digits)
'1234567890123456', // too long (16 digits)
'30.111.222', // formatted with dots — reject, not accept-and-strip
])('rejects a non-digits-only document number: %s', value => {
expect(isValidDocumentNumber(value)).toBe(false);
});
});
describe('formatArgentinePhone', () => {
it('formats a bare 10-digit local number in the national shape', () => {
expect(formatArgentinePhone('3435551234')).toBe('343 555-1234');
});
it('strips separators from an already-formatted number', () => {
expect(formatArgentinePhone('343 555-1234')).toBe('343 555-1234');
expect(formatArgentinePhone('(343) 555-1234')).toBe('343 555-1234');
});
it('returns a number of unexpected length unchanged', () => {
expect(formatArgentinePhone('123')).toBe('123');
expect(formatArgentinePhone('123456789012')).toBe('123456789012');
});
});
describe('formatDocumentNumber', () => {
it('adds dot thousands-separators to a valid document number', () => {
expect(formatDocumentNumber('38742615')).toBe('38.742.615');
expect(formatDocumentNumber('412281692')).toBe('412.281.692');
});
it('returns non-numeric input unchanged instead of mangling it', () => {
expect(formatDocumentNumber('d23')).toBe('d23');
});
});
describe('isAtLeastMinimumPlayerAge', () => {
// Builds "YYYY-MM-DD" from LOCAL date parts (not toISOString, which
// converts to UTC and can shift the calendar date near local midnight
// in timezones behind UTC, e.g. Argentina).
const isoDateYearsAgo = (years: number): string => {
const date = new Date();
date.setFullYear(date.getFullYear() - years);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
it('accepts someone older than the minimum age', () => {
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(20))).toBe(true);
});
it('accepts someone exactly at the minimum age', () => {
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(15))).toBe(true);
});
it('rejects someone younger than the minimum age', () => {
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(10))).toBe(false);
});
it('rejects an unparsable date', () => {
expect(isAtLeastMinimumPlayerAge('not-a-date')).toBe(false);
});
});
@@ -0,0 +1,143 @@
/**
* Shared email / phone validators reused by every form that collects contact
* data (user create/invite/edit, player create/edit, password recovery).
*
* The rules intentionally mirror the backend DataAnnotations in
* `Application.Utils.Constants.Validation.ValidationPatterns`, so the client and
* the server accept exactly the same values and the user never gets a 400 for
* input the form said was fine.
*/
/** Standard, permissive email shape: local@domain.tld with no whitespace. */
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Characters allowed in a plausible phone number. */
const PHONE_ALLOWED_CHARS_REGEX = /^[+\d\s()-]+$/;
/** A player's DNI/document number: digits only, 6 to 15 of them. */
const DOCUMENT_NUMBER_REGEX = /^\d{6,15}$/;
/**
* ASP.NET Core Identity's default `IdentityOptions.User.AllowedUserNameCharacters`
* (letters, digits, and `-._@+`) — mirrored here so an invalid username (most
* commonly one with spaces, since the field's Spanish label "Nombre de
* usuario" reads like it could take a full name) is caught client-side with a
* Spanish message, instead of round-tripping to the backend and surfacing its
* raw English Identity error ("Username '...' is invalid, can only contain
* letters or digits.") straight to the admin.
*/
const USERNAME_REGEX = /^[a-zA-Z0-9\-._@+]+$/;
/** Minimum age (years) a player must be, mirroring the backend's [MinimumAge(15)]. */
const PLAYER_MINIMUM_AGE = 15;
/** True when `value` is a syntactically valid email address. */
export function isValidEmail(value: string): boolean {
return EMAIL_REGEX.test(value.trim());
}
/**
* True when `value` is a plausible Argentine phone number: only digits,
* spaces, `+`, `-`, and parentheses, and exactly 10 digits — the national
* format (area code + local number) used for calls placed from inside the
* country, with no leading `0` trunk prefix, no `15`, no `+54` country code
* and no `9` mobile marker (those only apply to international dialing,
* which this app — a local league — never needs).
*/
export function isValidPhone(value: string): boolean {
const trimmed = value.trim();
if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) {
return false;
}
const digits = trimmed.replace(/\D/g, '');
return digits.length === 10;
}
/** True when `value` is a plausible DNI/document number: 6 to 15 digits only. */
export function isValidDocumentNumber(value: string): boolean {
return DOCUMENT_NUMBER_REGEX.test(value.trim());
}
/**
* True when `value` is a valid username: letters, digits, and `-._@+` only
* (no spaces), matching the backend's actual accepted character set.
*/
export function isValidUsername(value: string): boolean {
return USERNAME_REGEX.test(value.trim());
}
/**
* True when `birthDate` (an `<input type="date">` value, "YYYY-MM-DD") puts
* the person at least {@link PLAYER_MINIMUM_AGE} years old today. Parses the
* Y/M/D components explicitly and builds a LOCAL date rather than relying on
* `new Date("YYYY-MM-DD")` — that form is UTC-midnight per spec, which would
* silently shift the effective date by a day in any timezone behind UTC (all
* of Argentina), misjudging someone born exactly on the cutoff date. An
* unparsable value is treated as invalid — the caller already requires the
* field, so an empty/malformed string should never reach here as "valid".
*/
export function isAtLeastMinimumPlayerAge(birthDate: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate.trim());
if (!match) {
return false;
}
const [, year, month, day] = match;
const parsed = new Date(Number(year), Number(month) - 1, Number(day));
if (Number.isNaN(parsed.getTime())) {
return false;
}
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setFullYear(cutoff.getFullYear() - PLAYER_MINIMUM_AGE);
return parsed.getTime() <= cutoff.getTime();
}
/**
* Formats a phone number for display in the Argentine national shape, e.g.
* "3435551234" → "343 555-1234" — no "+54" country code and no "9" mobile
* marker, since this app is only ever dialed from inside the country. Only
* a 10-digit local number (area code + line, the shape every phone in this
* app is stored as) can be confidently split into area/exchange/line
* without an area-code length table, so anything else is returned
* unchanged rather than mangled.
*/
export function formatArgentinePhone(value: string): string {
const digits = value.replace(/\D/g, '');
if (digits.length !== 10) {
return value;
}
const area = digits.slice(0, 3);
const exchange = digits.slice(3, 6);
const line = digits.slice(6);
return `${area} ${exchange}-${line}`;
}
/**
* Formats a DNI/document number with dot thousands-separators for display
* (e.g. "38742615" → "38.742.615", matching the printed-DNI convention).
* Non-numeric input (legacy/test data) is returned unchanged rather than
* mangled.
*/
export function formatDocumentNumber(value: string): string {
if (!isValidDocumentNumber(value)) {
return value;
}
return Number(value).toLocaleString('es-AR');
}
/** Spanish (voseo) helper/error messages shown under the fields. */
export const VALIDATION_MESSAGES = {
email: 'Ingresá un email válido',
phone: 'Ingresá un teléfono válido',
documentNumber: 'El documento debe tener solo números',
minimumPlayerAge: `El jugador debe tener al menos ${PLAYER_MINIMUM_AGE} años`,
username: 'El nombre de usuario no puede contener espacios ni símbolos (solo letras, números y - . _ @ +)',
} as const;
@@ -0,0 +1,28 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { sendPost } from '@/modules/core/utils/axiosUtils';
import {
IDataSeedResult,
IDataWipeResult,
} from '@/modules/dataMaintenance/type/dataMaintenance';
/**
* Admin-only tools for resetting tournament-domain data to a clean,
* realistic sample state.
*/
export const dataMaintenanceService = {
/**
* Deletes every tournament-domain row. Identity is untouched.
* @returns {Promise<AxiosResponse<IDataWipeResult>>} Row counts removed.
*/
wipeSampleData: async (): Promise<AxiosResponse<IDataWipeResult>> =>
await sendPost(`${routes.dataMaintenance}/wipe`),
/**
* Seeds 2 complete sample tournaments. Rejects with a 409 response if
* the database already has tournament data.
* @returns {Promise<AxiosResponse<IDataSeedResult>>} Row counts created.
*/
seedSampleData: async (): Promise<AxiosResponse<IDataSeedResult>> =>
await sendPost(`${routes.dataMaintenance}/seed`),
};
@@ -0,0 +1,26 @@
export interface IDataWipeResult {
tournaments: number;
divisions: number;
teams: number;
players: number;
matches: number;
matchSeries: number;
playerSanctions: number;
playerStatistics: number;
scorers: number;
stageTeamMatches: number;
playerTeamRegistrations: number;
stages: number;
venues: number;
blogPosts: number;
}
export interface IDataSeedResult {
tournaments: number;
divisions: number;
teams: number;
players: number;
matches: number;
playerSanctions: number;
blogPosts: number;
}
@@ -0,0 +1,83 @@
import { act, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import Swal from 'sweetalert2';
import { ErrorProvider } from '@/modules/error/context/error.context';
import { DivisionProvider } from '@/modules/division/context/division.context';
import { useDivision } from '@/modules/division/hook/division.hook';
import { divisionService } from '@/modules/division/service/division.service';
import type { GUID } from '@/modules/core/types/types';
vi.mock('@/modules/division/service/division.service');
vi.mock('sweetalert2', () => ({
default: {
fire: vi.fn(),
getContainer: vi.fn().mockReturnValue(null),
},
}));
const mockedPutDivisionById = vi.mocked(divisionService.putDivisionById);
const mockedDeleteDivisionsById = vi.mocked(divisionService.deleteDivisionsById);
const mockedSwalFire = vi.mocked(Swal.fire);
const DIVISION_ID = '55555555-5555-5555-5555-555555555555' as GUID;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
<ErrorProvider>
<DivisionProvider>{children}</DivisionProvider>
</ErrorProvider>
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
});
describe('DivisionProvider — no duplicate success toast', () => {
/**
* divisionEditPage.tsx / divisionsPage.tsx already show their own
* confirmation for these actions. The context used to ALSO fire a toast, so
* the user saw two modals with the same message for one action.
*/
it('does not fire its own toast after putDivisionById succeeds (200)', async () => {
mockedPutDivisionById.mockResolvedValueOnce({
status: 200,
data: { id: DIVISION_ID, name: 'Zona B' },
} as never);
const { result } = renderHook(() => useDivision(), { wrapper });
await act(async () => {
await result.current.putDivisionById(DIVISION_ID, {
name: 'Zona B',
} as never);
});
expect(mockedSwalFire).not.toHaveBeenCalled();
});
it('does not fire its own toast after putDivisionById succeeds (204)', async () => {
mockedPutDivisionById.mockResolvedValueOnce({ status: 204 } as never);
const { result } = renderHook(() => useDivision(), { wrapper });
await act(async () => {
await result.current.putDivisionById(DIVISION_ID, {
name: 'Zona B',
} as never);
});
expect(mockedSwalFire).not.toHaveBeenCalled();
});
it('does not fire its own toast after deleteDivisionsById succeeds', async () => {
mockedDeleteDivisionsById.mockResolvedValueOnce({ status: 204 } as never);
const { result } = renderHook(() => useDivision(), { wrapper });
await act(async () => {
await result.current.deleteDivisionsById(DIVISION_ID);
});
expect(mockedSwalFire).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,410 @@
import { AxiosResponse } from 'axios';
import {
createContext,
ReactNode,
useEffect,
useState,
useCallback,
useMemo,
} from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
FetchOptions,
GenericResponsePagination,
GUID,
} from '@/modules/core/types/types';
import { useError } from '@/modules/error/hooks/error.hock';
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
import { divisionService } from '@/modules/division/service/division.service';
import {
AddDivisionRequest,
DivisionFiltered,
IDivisionResponse,
IDivisionContextProps,
IPutDivisionRequest,
} from '@/modules/division/type/division';
import { ITeamResponse } from '@/modules/team/type/team.d';
import { upsertListById } from '@/modules/core/utils/synchronizeStates';
import { divisionKeys } from '@/modules/division/queryKeys';
import { HttpStatus } from '@/modules/core/constants/httpStatus';
export const DivisionContext = createContext<IDivisionContextProps | undefined>(
undefined
);
export const DivisionProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [division, setDivision] = useState<IDivisionResponse | null>(null);
const [divisions, setDivisions] = useState<IDivisionResponse[] | null>(null);
const { setMessage } = useError();
const queryClient = useQueryClient();
const handleUnknownError = useUnknownErrorHandler();
const addDivisionMutation = useMutation({
mutationFn: divisionService.addDivision,
});
const generateFixtureMutation = useMutation({
mutationFn: divisionService.generateFixtureByDivisionId,
});
const putDivisionMutation = useMutation({
mutationFn: ({
id,
divisionRequest,
}: {
id: GUID;
divisionRequest: IPutDivisionRequest;
}) => divisionService.putDivisionById(id, divisionRequest),
});
const deleteDivisionMutation = useMutation({
mutationFn: divisionService.deleteDivisionsById,
});
const enrollTeamsMutation = useMutation({
mutationFn: ({
divisionId,
teamIds,
}: {
divisionId: GUID;
teamIds: GUID[];
}) => divisionService.enrollTeams(divisionId, teamIds),
});
const unenrollTeamsMutation = useMutation({
mutationFn: ({
divisionId,
teamIds,
}: {
divisionId: GUID;
teamIds: GUID[];
}) => divisionService.unenrollTeams(divisionId, teamIds),
});
const autoDistributeMutation = useMutation({
mutationFn: divisionService.autoDistribute,
});
const rebuildSubGroupsMutation = useMutation({
mutationFn: ({
divisionId,
subGroupCount,
}: {
divisionId: GUID;
subGroupCount: number;
}) => divisionService.rebuildSubGroups(divisionId, subGroupCount),
});
const reassignTeamToSubGroupMutation = useMutation({
mutationFn: ({
divisionId,
teamId,
fromStageId,
toStageId,
}: {
divisionId: GUID;
teamId: GUID;
fromStageId: GUID;
toStageId: GUID;
}) =>
divisionService.reassignTeamToSubGroup(
divisionId,
teamId,
fromStageId,
toStageId
),
});
useEffect(() => {
if (!division) return;
setDivisions(prev => upsertListById(prev, division));
}, [division]);
const addDivision = useCallback(
async (
divisionRequest: AddDivisionRequest
): Promise<IDivisionResponse | void> => {
try {
const res: AxiosResponse<IDivisionResponse> =
await addDivisionMutation.mutateAsync(divisionRequest);
if (res && res.data) {
setDivision(res.data);
queryClient.setQueryData(divisionKeys.byId(res.data.id), res);
setMessage(res.status, ['Division creada exitosamente']);
await queryClient.invalidateQueries({
queryKey: divisionKeys.list(),
});
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[addDivisionMutation, queryClient, setMessage, handleUnknownError]
);
const generateFixtureByDivisionId = useCallback(
async (id: GUID): Promise<void> => {
try {
await generateFixtureMutation.mutateAsync(id);
setMessage(HttpStatus.Ok, ['Fixture generado exitosamente']);
} catch (error: unknown) {
handleUnknownError(error);
}
},
[generateFixtureMutation, setMessage, handleUnknownError]
);
const putDivisionById = useCallback(
async (
id: GUID,
divisionRequest: IPutDivisionRequest
): Promise<boolean | void> => {
try {
const res: AxiosResponse<IDivisionResponse> =
await putDivisionMutation.mutateAsync({ id, divisionRequest });
// Success feedback belongs to the calling page (divisionEditPage.tsx
// shows its own confirmation) — a toast here too means two modals.
if (res && res.status === HttpStatus.NoContent) {
setDivision(prev => {
if (!prev || prev.id !== id) return prev;
return {
...prev,
name: divisionRequest.name,
};
});
await queryClient.invalidateQueries({
queryKey: divisionKeys.list(),
});
return true;
} else if (res && res.data) {
setDivision(res.data);
queryClient.setQueryData(divisionKeys.byId(id), res);
await queryClient.invalidateQueries({
queryKey: divisionKeys.list(),
});
return true;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[putDivisionMutation, setDivision, queryClient, handleUnknownError]
);
const getDivisionsById = useCallback(
async (idOrSlug: string): Promise<IDivisionResponse | void> => {
try {
// Always fetch the full `/detail` projection. The cached list version
// (from getDivisionsByFilters) is a lighter shape without positions,
// group standings or qualificationRanges, so short-circuiting to it
// left the admin detail view's standings uncoloured (HU-45) — unlike
// the public panel, which calls the service directly and always hits
// `/detail`.
const res: AxiosResponse<IDivisionResponse> =
await queryClient.fetchQuery({
queryKey: divisionKeys.byId(idOrSlug),
queryFn: async () =>
await divisionService.getDivisionsById(idOrSlug),
});
if (res && res.data) {
setDivision(res.data);
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[setDivision, queryClient, handleUnknownError]
);
const getDivisionsByFilters = useCallback(
async (
filter: DivisionFiltered,
options?: FetchOptions
): Promise<GenericResponsePagination<IDivisionResponse> | void> => {
try {
const res = await queryClient.fetchQuery({
queryKey: divisionKeys.list(filter),
queryFn: async () =>
await divisionService.getDivisionsByFilters(filter),
});
if (res?.data?.items) {
setDivisions(res.data.items);
return res.data;
}
} catch (error: unknown) {
if (!options?.silent) handleUnknownError(error);
}
},
[setDivisions, queryClient, handleUnknownError]
);
const deleteDivisionsById = useCallback(
async (id: GUID): Promise<boolean> => {
try {
await deleteDivisionMutation.mutateAsync(id);
setDivision(null);
setDivisions(prev => (prev ? prev.filter(e => e.id !== id) : null));
queryClient.removeQueries({ queryKey: divisionKeys.byId(id) });
await queryClient.invalidateQueries({ queryKey: divisionKeys.list() });
// Success feedback belongs to the calling page (divisionsPage.tsx shows
// its own "¡Eliminada!" confirmation) — a toast here too means two modals.
return true;
} catch (error: unknown) {
handleUnknownError(error);
return false;
}
},
[deleteDivisionMutation, queryClient, handleUnknownError]
);
const getRoster = useCallback(
async (divisionId: GUID): Promise<ITeamResponse[] | void> => {
try {
const res = await queryClient.fetchQuery({
queryKey: divisionKeys.roster(divisionId),
queryFn: async () => await divisionService.getRoster(divisionId),
});
if (res?.data) {
return res.data;
}
} catch (error: unknown) {
handleUnknownError(error);
}
},
[queryClient, handleUnknownError]
);
const enrollTeams = useCallback(
async (divisionId: GUID, teamIds: GUID[]): Promise<boolean | void> => {
try {
await enrollTeamsMutation.mutateAsync({ divisionId, teamIds });
await queryClient.invalidateQueries({
queryKey: divisionKeys.roster(divisionId),
});
return true;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[enrollTeamsMutation, queryClient, handleUnknownError]
);
const unenrollTeams = useCallback(
async (divisionId: GUID, teamIds: GUID[]): Promise<boolean | void> => {
try {
await unenrollTeamsMutation.mutateAsync({ divisionId, teamIds });
await queryClient.invalidateQueries({
queryKey: divisionKeys.roster(divisionId),
});
return true;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[unenrollTeamsMutation, queryClient, handleUnknownError]
);
const autoDistribute = useCallback(
async (divisionId: GUID): Promise<boolean | void> => {
try {
await autoDistributeMutation.mutateAsync(divisionId);
return true;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[autoDistributeMutation, handleUnknownError]
);
const rebuildSubGroups = useCallback(
async (divisionId: GUID, subGroupCount: number): Promise<boolean | void> => {
try {
await rebuildSubGroupsMutation.mutateAsync({ divisionId, subGroupCount });
await queryClient.invalidateQueries({
queryKey: divisionKeys.roster(divisionId),
});
return true;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[rebuildSubGroupsMutation, queryClient, handleUnknownError]
);
const reassignTeamToSubGroup = useCallback(
async (
divisionId: GUID,
teamId: GUID,
fromStageId: GUID,
toStageId: GUID
): Promise<boolean | void> => {
try {
await reassignTeamToSubGroupMutation.mutateAsync({
divisionId,
teamId,
fromStageId,
toStageId,
});
await queryClient.invalidateQueries({
queryKey: divisionKeys.roster(divisionId),
});
return true;
} catch (error: unknown) {
handleUnknownError(error);
}
},
[reassignTeamToSubGroupMutation, queryClient, handleUnknownError]
);
const container: IDivisionContextProps = useMemo(
() => ({
division,
divisions,
addDivision,
generateFixtureByDivisionId,
putDivisionById,
getDivisionsByFilters,
getDivisionsById,
deleteDivisionsById,
getRoster,
enrollTeams,
unenrollTeams,
autoDistribute,
rebuildSubGroups,
reassignTeamToSubGroup,
}),
[
division,
divisions,
addDivision,
generateFixtureByDivisionId,
putDivisionById,
getDivisionsByFilters,
getDivisionsById,
deleteDivisionsById,
getRoster,
enrollTeams,
unenrollTeams,
autoDistribute,
rebuildSubGroups,
reassignTeamToSubGroup,
]
);
return (
<DivisionContext.Provider value={container}>
{children}
</DivisionContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { DivisionContext } from '@/modules/division/context/division.context';
export const useDivision = () => {
const context = useContext(DivisionContext);
if (!context) {
throw new Error('useDivision must be used within a DivisionProvider');
}
return context;
};
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { divisionKeys } from './queryKeys';
import { GUID } from '@/modules/core/types/types';
import { DivisionFiltered } from '@/modules/division/type/division';
describe('divisionKeys', () => {
const id: GUID = '88888888-8888-8888-8888-888888888888';
it('list() returns the bare list literal with no trailing undefined', () => {
expect(divisionKeys.list()).toEqual(['division', 'list']);
});
it('list(filter) returns the filtered list literal', () => {
const filter: DivisionFiltered = { pageNumber: 1 };
expect(divisionKeys.list(filter)).toEqual(['division', 'list', filter]);
});
it('byId(id) returns the by-id literal', () => {
expect(divisionKeys.byId(id)).toEqual(['division', 'byId', id]);
});
});
@@ -0,0 +1,10 @@
import { DivisionFiltered } from '@/modules/division/type/division';
export const divisionKeys = {
list: (filter?: DivisionFiltered) =>
filter === undefined
? (['division', 'list'] as const)
: (['division', 'list', filter] as const),
byId: (idOrSlug: string) => ['division', 'byId', idOrSlug] as const,
roster: (divisionId: string) => ['division', 'roster', divisionId] as const,
};
@@ -0,0 +1,170 @@
import { AxiosResponse } from 'axios';
import routes from '@/modules/core/constants/routes';
import { withTablePageSize } from '@/modules/core/constants/pagination';
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
import {
sendDelete,
sendGet,
sendPost,
sendPut,
} from '@/modules/core/utils/axiosUtils';
import {
AddDivisionRequest,
DivisionFiltered,
EnrollTeamsRequest,
IDivisionResponse,
IPutDivisionRequest,
ReassignTeamToSubGroupRequest,
RebuildSubGroupsRequest,
UnenrollTeamsRequest,
} from '@/modules/division/type/division';
import { ITeamResponse } from '@/modules/team/type/team.d';
import { IStageResponse } from '@/modules/stage/type/stage';
/**
* DivisionService provides methods to interact with the divisions API.
*/
export const divisionService = {
/**
* Adds a new division.
* @param {AddDivisionRequest} division - The division data to be added.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
*/
addDivision: async (
division: AddDivisionRequest
): Promise<AxiosResponse<IDivisionResponse>> =>
sendPost<IDivisionResponse>(routes.divisions, division),
/**
* Generates the fixture for a division based on its ID.
* @param {string} id - The ID of the division to generate the fixture for.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
*/
generateFixtureByDivisionId: async (id: GUID): Promise<AxiosResponse<void>> =>
sendPost<void>(`${routes.divisions}/${id}/generate-fixture`),
/**
* Updates an existing division by its ID.
* @param {string} id - The ID of the division to be updated.
* @param {IPutDivisionRequest} division - The updated division data.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
*/
putDivisionById: async (
id: GUID,
division: IPutDivisionRequest
): Promise<AxiosResponse<IDivisionResponse>> =>
sendPut<IDivisionResponse>(`${routes.divisions}/${id}`, division),
/**
* Retrieves a division by its ID or its public slug.
* @param {string} idOrSlug - The ID or slug of the division to retrieve.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the division data.
*/
getDivisionsById: async (
idOrSlug: string
): Promise<AxiosResponse<IDivisionResponse>> =>
sendGet<IDivisionResponse>(`${routes.divisions}/${idOrSlug}/detail`),
/**
* Retrieves divisions based on provided filters.
* @param {DivisionFiltered} filter - The filters to apply when retrieving divisions.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with a list of divisions matching the filter.
*/
getDivisionsByFilters: async (
filter: DivisionFiltered
): Promise<AxiosResponse<GenericResponsePagination<IDivisionResponse>>> =>
sendGet<GenericResponsePagination<IDivisionResponse>>(
routes.divisions,
withTablePageSize(filter)
),
/**
* Deletes a division by its ID.
* @param {string} id - The ID of the division to delete.
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves when the division is deleted.
*/
deleteDivisionsById: async (id: GUID): Promise<AxiosResponse<void>> =>
sendDelete<void>(`${routes.divisions}/${id}`),
/**
* Fetches every team enrolled in a division's roster.
* @param {GUID} divisionId - The division whose roster to fetch.
* @returns {Promise<AxiosResponse<ITeamResponse[]>>} - The enrolled teams.
*/
getRoster: async (divisionId: GUID): Promise<AxiosResponse<ITeamResponse[]>> =>
sendGet<ITeamResponse[]>(`${routes.divisions}/${divisionId}/roster`),
/**
* Enrols one or more teams in a division's roster.
* @param {GUID} divisionId - The division to enrol the teams into.
* @param {GUID[]} teamIds - The teams to enrol.
* @returns {Promise<AxiosResponse<ITeamResponse[]>>} - The division's roster as it stands after enrolling (200).
*/
enrollTeams: async (
divisionId: GUID,
teamIds: GUID[]
): Promise<AxiosResponse<ITeamResponse[]>> =>
sendPost<ITeamResponse[]>(`${routes.divisions}/${divisionId}/roster`, {
teamIds,
} satisfies EnrollTeamsRequest),
/**
* Removes one or more teams from a division's roster, cascading to any
* stage placement they still hold within the division.
* @param {GUID} divisionId - The division to unenrol the teams from.
* @param {GUID[]} teamIds - The teams to unenrol.
* @returns {Promise<AxiosResponse<void>>} - The response confirming removal.
*/
unenrollTeams: async (
divisionId: GUID,
teamIds: GUID[]
): Promise<AxiosResponse<void>> =>
sendDelete<void>(`${routes.divisions}/${divisionId}/roster`, undefined, {
teamIds,
} satisfies UnenrollTeamsRequest),
/**
* Clears the division's current sub-group placements and re-runs a
* balanced random distribution over its whole roster.
* @param {GUID} divisionId - The division whose sub-groups to auto-distribute.
* @returns {Promise<AxiosResponse<void>>} - The response confirming the redistribution.
*/
autoDistribute: async (divisionId: GUID): Promise<AxiosResponse<void>> =>
sendPost<void>(`${routes.divisions}/${divisionId}/roster/auto-distribute`),
/**
* Rebuilds a division's sub-group stage layer to a new count, keeping the
* roster untouched.
* @param {GUID} divisionId - The division whose sub-group count to change.
* @param {number} subGroupCount - The new sub-group count.
* @returns {Promise<AxiosResponse<IStageResponse[]>>} - The newly-built sub-group stages (200).
*/
rebuildSubGroups: async (
divisionId: GUID,
subGroupCount: number
): Promise<AxiosResponse<IStageResponse[]>> =>
sendPost<IStageResponse[]>(`${routes.divisions}/${divisionId}/sub-groups/rebuild`, {
subGroupCount,
} satisfies RebuildSubGroupsRequest),
/**
* Manually moves one enrolled team from one sub-group to another within
* the same division, without touching any other team's placement.
* @param {GUID} divisionId - The division the two sub-groups belong to.
* @param {GUID} teamId - The team to move.
* @param {GUID} fromStageId - The sub-group stage the team currently belongs to.
* @param {GUID} toStageId - The sub-group stage to move the team into.
* @returns {Promise<AxiosResponse<void>>} - The response confirming the move.
*/
reassignTeamToSubGroup: async (
divisionId: GUID,
teamId: GUID,
fromStageId: GUID,
toStageId: GUID
): Promise<AxiosResponse<void>> =>
sendPost<void>(`${routes.divisions}/${divisionId}/sub-groups/reassign`, {
teamId,
fromStageId,
toStageId,
} satisfies ReassignTeamToSubGroupRequest),
};
+543
View File
@@ -0,0 +1,543 @@
import {
FetchOptions,
Filtered,
GenericResponsePagination,
GUID,
} from '@/modules/core/types/types';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { ITeamResponse } from '@/modules/team/type/team.d';
/**
* Context properties and methods for managing divisions in a React application.
* These methods interact with the backend for creating, updating, fetching, and deleting divisions.
* @interface IDivisionContextProps
*/
export interface IDivisionContextProps {
division: IDivisionResponse | null;
divisions: IDivisionResponse[] | null;
/**
* Adds a new division to the system.
* @param division The details of the division to add.
* @returns A promise that resolves with the response containing the newly added division.
*/
addDivision(division: AddDivisionRequest): Promise<IDivisionResponse | void>;
/**
* Generates fixtures for a division based on its ID.
* @param id The ID of the division for which to generate fixtures.
* @returns A promise that resolves when the fixtures are successfully generated.
*/
generateFixtureByDivisionId(id: GUID): Promise<void>;
/**
* Updates an existing division by its ID.
* @param id The ID of the division to update.
* @param division The updated division data.
* @returns A promise that resolves with the response containing the updated division.
*/
putDivisionById(
id: GUID,
division: IPutDivisionRequest
): Promise<boolean | void>;
/**
* Fetches a division by its ID or its public slug.
* @param idOrSlug The ID or slug of the division to fetch.
* @returns A promise that resolves with the division data.
*/
getDivisionsById(idOrSlug: string): Promise<IDivisionResponse | void>;
/**
* Fetches divisions based on filters and pagination.
* @param filter The filter criteria to apply when fetching divisions.
* @returns A promise that resolves with a paginated response containing filtered divisions.
*/
getDivisionsByFilters(
filter: DivisionFiltered,
options?: FetchOptions
): Promise<GenericResponsePagination<IDivisionResponse> | void>;
/**
* Deletes a division by its ID.
* @param id The ID of the division to delete.
* @returns A promise resolving to `true` if the division was deleted,
* `false` if the request failed (the global error is already reported
* either way).
*/
deleteDivisionsById(id: GUID): Promise<boolean>;
/**
* Fetches every team currently enrolled in a division's roster
* (`DivisionTeamRegistration`), independent of any stage placement. The
* authoritative source of "who is in this division" — including a
* playoffs-only division with no group stage.
* @param divisionId The division whose roster to fetch.
* @returns A promise resolving to the enrolled teams, or void on failure.
*/
getRoster(divisionId: GUID): Promise<ITeamResponse[] | void>;
/**
* Enrols one or more teams in a division's roster. Rejected (409) when a
* team already holds a registration in another regular division of the
* same tournament, or the tournament structure is locked.
* @param divisionId The division to enrol the teams into.
* @param teamIds The teams to enrol.
* @returns A promise resolving to true on success, or void on failure.
*/
enrollTeams(divisionId: GUID, teamIds: GUID[]): Promise<boolean | void>;
/**
* Removes one or more teams from a division's roster. Cascades: any
* `StageTeamMatch` the team still holds within the division's stages is
* removed in the same operation.
* @param divisionId The division to unenrol the teams from.
* @param teamIds The teams to unenrol.
* @returns A promise resolving to true on success, or void on failure.
*/
unenrollTeams(divisionId: GUID, teamIds: GUID[]): Promise<boolean | void>;
/**
* Clears the division's current sub-group placements and re-runs a
* balanced random distribution over its whole roster (HU-122). Always
* balanced, not fill-only-empties.
* @param divisionId The division whose sub-groups to auto-distribute.
* @returns A promise resolving to true on success, or void on failure.
*/
autoDistribute(divisionId: GUID): Promise<boolean | void>;
/**
* Rebuilds a division's sub-group stage layer to a new count, keeping the
* roster untouched, and re-runs the balanced distribution over it (HU-123).
* @param divisionId The division whose sub-group count to change.
* @param subGroupCount The new sub-group count.
* @returns A promise resolving to true on success, or void on failure.
*/
rebuildSubGroups(divisionId: GUID, subGroupCount: number): Promise<boolean | void>;
/**
* Manually moves one enrolled team from one sub-group to another within the
* same division (HU-122), without touching any other team's placement.
* Rejected (409) when the move would drop the source sub-group below the
* minimum size, the team is not currently placed in `fromStageId`, or the
* two stages belong to different divisions.
* @param divisionId The division the two sub-groups belong to.
* @param teamId The team to move.
* @param fromStageId The sub-group stage the team currently belongs to.
* @param toStageId The sub-group stage to move the team into.
* @returns A promise resolving to true on success, or void on failure.
*/
reassignTeamToSubGroup(
divisionId: GUID,
teamId: GUID,
fromStageId: GUID,
toStageId: GUID
): Promise<boolean | void>;
}
/**
* One position-range → playoff-destination entry (HU-45) sent with a
* division so the backend can seed multiple cups from the final table
* (HU-81). Field names mirror the backend `PlayoffMappingRequest` DTO.
* @interface PlayoffMappingRequest
*/
export interface PlayoffMappingRequest {
/** First standings position in the range (1-based, inclusive). */
fromPosition: number;
/** Last standings position in the range (1-based, inclusive). */
toPosition: number;
/** The destination cup's BracketName (e.g. "Copa Oro"). */
destination: string;
}
/**
* The request body structure for adding a new division.
* @interface AddDivisionRequest
*/
export interface AddDivisionRequest {
/**
* The name of the division.
* @type {string}
*/
name: string;
/**
* The ID of the tournament to which the division belongs.
* @type {GUID}
*/
tournamentId: GUID;
/**
* Marks this division as a cross-division cup that intentionally draws
* teams from every other division in the tournament (e.g. an
* admin-named "Copa Club12"), exempt from the "one team, one division"
* rule. Defaults to false.
* @type {boolean}
*/
isCrossDivisionCup?: boolean;
/**
* For a cross-division cup (HU-110): how many teams advance from each of
* the cup's group stages into the pooled knockout bracket. Only meaningful
* when `isCrossDivisionCup` is true; the backend auto-sizes the bracket's
* first round from the pooled top-`qualifiersPerGroup` of every group.
* @type {number}
*/
qualifiersPerGroup?: number;
/**
* Points awarded for a win in this division's standings (HU-79).
* Omit to let the backend default to 2.
* @type {number}
*/
pointsForWin?: number;
/**
* Points awarded for a loss in this division's standings (HU-79).
* Omit to let the backend default to 1.
* @type {number}
*/
pointsForLoss?: number;
/**
* Competitive category (gender) of the division (HU-48). MUST match the
* parent tournament's category — the backend rejects a division whose
* category differs from its tournament, and `Division.Category` defaults to
* Masculine server-side. The wizard therefore sends the tournament's
* category on every division so a Feminine tournament's zones are created
* as Feminine and not rejected.
* @type {TournamentCategory}
*/
category?: TournamentCategory;
/**
* Optional position-range → playoff-destination mappings (HU-45) the
* wizard sends so the backend can seed multiple cups (HU-81). Ranges
* must not overlap.
* @type {PlayoffMappingRequest[]}
*/
playoffMappings?: PlayoffMappingRequest[];
}
/**
* One standings-position range that qualifies to a playoff cup (HU-45),
* shaped for the public standings table so it can highlight the qualifying
* rows and render a per-cup legend. Mirrors the backend
* `QualificationRangeResponse` DTO.
* @interface QualificationRange
*/
export interface QualificationRange {
/** First standings position in the range (1-based, inclusive). */
fromPosition: number;
/** Last standings position in the range (1-based, inclusive). */
toPosition: number;
/** The cup the teams in this range qualify for (e.g. "Copa Oro"). */
cupName: string;
/**
* The cup's rank, top-down: 0 is the top cup ("Copa Oro"), 1 the next, and
* so on. Drives the color painted on each qualifying row.
*/
order: number;
}
/**
* The response structure for a division, including details about the division, its matches, and positions.
* @interface IDivisionResponse
*/
export interface IDivisionResponse {
/**
* The unique identifier of the division.
* @type {GUID}
*/
id: GUID;
/**
* The name of the division.
* @type {string}
*/
name: string;
/**
* The unique, URL-friendly identifier used in public division links.
* @type {string}
*/
slug: string;
/**
* Indicates whether the division has finished.
* @type {boolean}
*/
isFinished: boolean;
/**
* The list of positions for teams in the division. For a multi-group
* cross-division cup this is the pooled union across every internal group
* (so a team counter reflects all groups); use `groupStandings` to render
* one table per group.
* @type {Position[]}
*/
positions?: Position[];
/**
* One standings table per Group stage (HU-110). A regular zone has a single
* entry; a multi-group cross-division cup has one per internal group
* ("Grupo 1".."Grupo N"). Absent/empty when the division has no group stage.
* @type {GroupStandings[]}
*/
groupStandings?: GroupStandings[];
/**
* The ID of the tournament to which the division belongs.
* @type {GUID}
*/
tournamentId: GUID;
/**
* The parent tournament's slug, when it was resolved by the backend; null
* otherwise. Prefer this over `tournamentId` when building a link back to
* the tournament, so the URL never shows a raw UUID.
* @type {string | null}
*/
tournamentSlug?: string | null;
/**
* Whether this division is a cross-division cup (exempt from the "one
* team, one division" rule).
* @type {boolean}
*/
isCrossDivisionCup: boolean;
/**
* For a cross-division cup (HU-110): how many teams advance from EACH of
* the cup's internal groups into the pooled knockout bracket. Meaningless
* (defaults to 1) outside a cross-division cup.
* @type {number}
*/
qualifiersPerGroup?: number;
/**
* Competitive category (gender) of the division — matches its tournament.
* Used to tell apart same-named zones across masculine/feminine tournaments.
* @type {TournamentCategory}
*/
category?: TournamentCategory;
/**
* The standings-position ranges that qualify to a playoff cup (HU-45),
* ordered top-down (order 0 = top cup). Lets the public standings table
* highlight the qualifying rows and render a per-cup legend. Absent/empty
* when the division has no playoff mappings.
* @type {QualificationRange[]}
*/
qualificationRanges?: QualificationRange[];
}
/**
* Standings for a single Group stage within a division. A regular zone has
* exactly one; a multi-group cross-division cup (HU-110) has one per internal
* group, each computed only over that group's own matches.
* @type GroupStandings
*/
export type GroupStandings = {
/** The id of the Group stage these standings belong to. */
stageId: GUID;
/** The Group stage's name, used as the table label (e.g. "Grupo 1"). */
stageName: string;
/** The ordered standings for the teams in this group. */
positions: Position[];
};
/**
* The structure for a position in a division, including team statistics.
* @type Position
*/
export type Position = {
/**
* The unique identifier of the team.
* @type {string}
*/
teamId: GUID;
/**
* The name of the team.
* @type {string}
*/
teamName: string;
/**
* The URL of the team's logo.
* @type {string}
*/
logoUrl: string;
/**
* The number of matches the team has played.
* @type {number}
*/
matchesPlayed: number;
/**
* The number of matches the team has won.
* @type {number}
*/
wins: number;
/**
* The number of matches the team has lost.
* @type {number}
*/
losses: number;
/**
* The number of points the team has scored.
* @type {number}
*/
pointsFor: number;
/**
* The number of points scored against the team.
* @type {number}
*/
pointsAgainst: number;
/**
* The difference between points scored and points against.
* @type {number}
*/
pointsDifference: number;
/**
* The total points the team has earned. Any disciplinary deduction
* (see `pointDeduction`) is already subtracted from this value.
* @type {number}
*/
points: number;
/**
* The disciplinary point deduction (deducción de puntos) applied to this
* team, when any. Absent when the team has no deduction. The subtraction is
* already reflected in `points`; this only carries the amount and reason so
* the standings can show a subtle "-N (motivo)" note.
*/
pointDeduction?: AppliedPointDeduction;
};
/**
* The point-deduction summary attached to a standings row when a team carries
* one or more disciplinary deductions. Mirrors the backend
* `AppliedPointDeductionResponse` DTO.
* @type AppliedPointDeduction
*/
export type AppliedPointDeduction = {
/** The total table points subtracted from the team. Always positive. */
points: number;
/** The combined disciplinary reason(s). */
reason: string;
};
/**
* The filter criteria for fetching divisions, which extends from PutDivisionRequest and Filtered.
* This includes the `isFinished` property to filter divisions by their completion status.
* @interface DivisionFiltered
* @extends IPutDivisionRequest
* @extends Filtered
*/
export interface DivisionFiltered extends Filtered {
/**
* Indicates whether to fetch finished divisions only.
* @type {boolean}
*/
isFinished?: boolean;
tournamentId?: GUID;
/**
* The updated name of the division.
* @type {string}
*/
name?: string;
}
/**
* The request body structure for updating an existing division.
* @interface PutDivisionRequest
*/
export interface IPutDivisionRequest {
/**
* The updated name of the division.
* @type {string}
*/
name: string;
isFinished: boolean;
}
export interface IDivisionPropsView {
name: string;
}
/**
* The request body to enrol teams in a division's roster.
* @interface EnrollTeamsRequest
*/
export interface EnrollTeamsRequest {
teamIds: GUID[];
}
/**
* The request body to remove teams from a division's roster.
* @interface UnenrollTeamsRequest
*/
export interface UnenrollTeamsRequest {
teamIds: GUID[];
}
/**
* The request body to change a division's sub-group count (HU-123).
* @interface RebuildSubGroupsRequest
*/
export interface RebuildSubGroupsRequest {
subGroupCount: number;
}
/**
* The request body to manually move one team from one sub-group to another
* within the same division (HU-122).
* @interface ReassignTeamToSubGroupRequest
*/
export interface ReassignTeamToSubGroupRequest {
teamId: GUID;
fromStageId: GUID;
toStageId: GUID;
}
/**
* The minimal response structure for a division, as embedded within a tournament response.
* @interface IMinimalDivisionResponse
*/
export interface IMinimalDivisionResponse {
/**
* The unique identifier of the division.
* @type {GUID}
*/
id: GUID;
/**
* The name of the division.
* @type {string}
*/
name: string;
/**
* Indicates whether the division has finished.
* @type {boolean}
*/
isFinished: boolean;
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { StageType } from '@/modules/stage/type/stage';
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d';
import { findDivisionStructure } from './divisionStructureSummary';
const groupStage = (name: string): ITournamentStructureResponse['divisions'][number]['stages'][number] => ({
name,
bracketName: null,
stageType: StageType.Group,
isElimination: false,
order: 0,
bestOf: 1,
roundRobinLegs: 2,
});
const cupStages = (bracketName: string) => [
{
name: `Semifinal ${bracketName}`,
bracketName,
stageType: StageType.SemiFinal,
isElimination: true,
order: 1,
bestOf: 3,
roundRobinLegs: 1,
},
{
name: `Final ${bracketName}`,
bracketName,
stageType: StageType.Final,
isElimination: true,
order: 2,
bestOf: 5,
roundRobinLegs: 1,
},
];
const tournamentStructure: ITournamentStructureResponse = {
name: 'Apertura 2026',
category: TournamentCategory.Masculine,
divisions: [
{
name: 'Zona A',
isCrossDivisionCup: false,
pointsForWin: 3,
pointsForLoss: 0,
qualifiersPerGroup: 1,
playoffMappings: [{ id: 'mapping-1', fromPosition: 1, toPosition: 4, destination: 'Copa Oro' } as never],
stages: [groupStage('Fase de Grupos'), ...cupStages('Copa Oro')],
},
{
name: 'Copa Club12',
isCrossDivisionCup: true,
pointsForWin: 2,
pointsForLoss: 1,
qualifiersPerGroup: 2,
playoffMappings: [],
stages: [groupStage('Grupo 1'), groupStage('Grupo 2'), ...cupStages('Copa Club12')],
},
],
};
describe('findDivisionStructure', () => {
it('resolves a regular zone by name', () => {
const summary = findDivisionStructure(tournamentStructure, 'Zona A');
expect(summary).not.toBeNull();
expect(summary!.zone).toBeDefined();
expect(summary!.crossCup).toBeUndefined();
expect(summary!.zone!.name).toBe('Zona A');
expect(summary!.zone!.cups).toHaveLength(1);
expect(summary!.zone!.cups[0].name).toBe('Copa Oro');
expect(summary!.review).toEqual([]);
});
it('resolves the cross-division cup by name', () => {
const summary = findDivisionStructure(tournamentStructure, 'Copa Club12');
expect(summary).not.toBeNull();
expect(summary!.crossCup).toBeDefined();
expect(summary!.zone).toBeUndefined();
expect(summary!.crossCup!.groupCount).toBe(2);
expect(summary!.crossCup!.cups[0].name).toBe('Copa Club12');
});
it('returns null when no division in the tournament matches the given name', () => {
expect(findDivisionStructure(tournamentStructure, 'Zona Inexistente')).toBeNull();
});
});
@@ -0,0 +1,43 @@
import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d';
import { structureToWizardState } from '@/views/tournament/wizard/cloneWizard';
import { CrossCupConfig, ZoneConfig } from '@/views/tournament/wizard/types';
/**
* One division's structure, resolved out of its tournament's full structure
* tree — a regular zone or the cross-division cup, never both. `review`
* carries any derivation mismatches found while reconstructing it (the same
* checks the tournament-cloning reverse-mapper runs), surfaced here as
* advisory notices rather than silently guessed.
*/
export interface DivisionStructureSummary {
zone?: ZoneConfig;
crossCup?: CrossCupConfig;
review: string[];
}
/**
* Resolves a single division's structure by name out of its tournament's
* structure tree, reusing the tournament-cloning reverse-mapper
* ({@link structureToWizardState}) instead of a second, parallel parser.
* Returns null when no division in the tournament matches the given name.
*/
export const findDivisionStructure = (
tournamentStructure: ITournamentStructureResponse,
divisionName: string
): DivisionStructureSummary | null => {
const { state, review } = structureToWizardState(
tournamentStructure,
tournamentStructure.category
);
const zone = state.zones.find(candidate => candidate.name === divisionName);
if (zone) {
return { zone, review };
}
if (state.crossCup.enabled && state.crossCup.name === divisionName) {
return { crossCup: state.crossCup, review };
}
return null;
};
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { QualificationRange } from '@/modules/division/type/division.d';
import {
buildCrossCupGroupQualificationRange,
cupTierColor,
cupTierMarker,
findQualificationRange,
} from './qualificationRange';
import { cupTier } from '@/design/tokens';
const ranges: QualificationRange[] = [
{ fromPosition: 1, toPosition: 4, cupName: 'Copa Oro', order: 0 },
{ fromPosition: 5, toPosition: 8, cupName: 'Copa Plata', order: 1 },
];
describe('findQualificationRange', () => {
it('matches the first position of a range (inclusive lower bound)', () => {
expect(findQualificationRange(ranges, 1)?.cupName).toBe('Copa Oro');
});
it('matches the last position of a range (inclusive upper bound)', () => {
expect(findQualificationRange(ranges, 8)?.cupName).toBe('Copa Plata');
});
it('matches a position inside a range', () => {
expect(findQualificationRange(ranges, 6)?.cupName).toBe('Copa Plata');
});
it('returns undefined for a position outside every range', () => {
expect(findQualificationRange(ranges, 9)).toBeUndefined();
});
it('returns undefined when there are no ranges', () => {
expect(findQualificationRange(undefined, 1)).toBeUndefined();
expect(findQualificationRange([], 1)).toBeUndefined();
});
});
describe('cupTierColor', () => {
it('maps order to gold, silver, bronze then the accent', () => {
expect(cupTierColor(0)).toBe(cupTier.gold);
expect(cupTierColor(1)).toBe(cupTier.silver);
expect(cupTierColor(2)).toBe(cupTier.bronze);
expect(cupTierColor(3)).toBe(cupTier.accent);
expect(cupTierColor(7)).toBe(cupTier.accent);
});
});
describe('buildCrossCupGroupQualificationRange', () => {
it('builds a single range covering positions 1..qualifiersPerGroup, named after the cup', () => {
const result = buildCrossCupGroupQualificationRange({
name: 'Copa Club12',
qualifiersPerGroup: 2,
});
expect(result).toEqual([
{ fromPosition: 1, toPosition: 2, cupName: 'Copa Club12', order: 0 },
]);
});
it('returns undefined when qualifiersPerGroup is missing or non-positive', () => {
expect(
buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: undefined })
).toBeUndefined();
expect(
buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: 0 })
).toBeUndefined();
});
});
describe('cupTierMarker', () => {
it('returns a distinct marker per tier so the legend does not rely on color alone', () => {
expect(cupTierMarker(0)).toBe('🟡');
expect(cupTierMarker(1)).toBe('⚪');
expect(cupTierMarker(2)).toBe('🟠');
expect(cupTierMarker(4)).toBe('🔶');
});
});
@@ -0,0 +1,76 @@
import { IDivisionResponse, QualificationRange } from '@/modules/division/type/division.d';
import { cupTier } from '@/design/tokens';
/**
* Finds the qualification range whose [fromPosition, toPosition] span contains
* the given 1-based standings position, or `undefined` when no range covers it
* (that row does not qualify to any cup). Ranges never overlap (the backend
* enforces it), so at most one can match.
*/
export const findQualificationRange = (
ranges: QualificationRange[] | undefined,
position: number
): QualificationRange | undefined =>
ranges?.find(range => position >= range.fromPosition && position <= range.toPosition);
/**
* The tier color a cup is painted with, by its top-down order: 0 gold, 1
* silver, 2 bronze, and the brand-orange accent for any further cup. Reads the
* centralized design tokens so the standings highlight and legend never
* hardcode a hex.
*/
export const cupTierColor = (order: number): string => {
switch (order) {
case 0:
return cupTier.gold;
case 1:
return cupTier.silver;
case 2:
return cupTier.bronze;
default:
return cupTier.accent;
}
};
/**
* HU-110/HU-112: a multi-group cross-division cup pools the top
* `qualifiersPerGroup` of EVERY internal group into one bracket — there is no
* per-division `PlayoffMappings` breakdown to derive from (the cross cup
* carries none, see backend `DivisionProfile.cs`). This is the single range
* every group's standings table highlights, named after the cup itself.
* Returns `undefined` when the division has no positive qualifiers-per-group
* (a regular zone, or a misconfigured cross cup).
*/
export const buildCrossCupGroupQualificationRange = (
division: Pick<IDivisionResponse, 'qualifiersPerGroup' | 'name'>
): QualificationRange[] | undefined => {
if (!division.qualifiersPerGroup || division.qualifiersPerGroup < 1) {
return undefined;
}
return [
{
fromPosition: 1,
toPosition: division.qualifiersPerGroup,
cupName: division.name,
order: 0,
},
];
};
/**
* A small emoji marker per tier, so the legend conveys the cup rank without
* relying on color alone (the cup name text carries the full meaning).
*/
export const cupTierMarker = (order: number): string => {
switch (order) {
case 0:
return '🟡';
case 1:
return '⚪';
case 2:
return '🟠';
default:
return '🔶';
}
};

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