diff --git a/Club12-WebClient/src/App.css b/Club12-WebClient/src/App.css
new file mode 100644
index 0000000..d08b22f
--- /dev/null
+++ b/Club12-WebClient/src/App.css
@@ -0,0 +1,3 @@
+body{
+ margin: 0 !important;
+}
\ No newline at end of file
diff --git a/Club12-WebClient/src/App.test.tsx b/Club12-WebClient/src/App.test.tsx
new file mode 100644
index 0000000..7ec0d81
--- /dev/null
+++ b/Club12-WebClient/src/App.test.tsx
@@ -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(
+
+
+
+ );
+
+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();
+ });
+});
diff --git a/Club12-WebClient/src/App.tsx b/Club12-WebClient/src/App.tsx
new file mode 100644
index 0000000..94fcb5d
--- /dev/null
+++ b/Club12-WebClient/src/App.tsx
@@ -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 boundary the
+// 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> = {
+ [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: },
+ {
+ path: APP_ROUTES.panelPlayers,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelPlayer.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelTeamDetail.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelTournamentDetail.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelTournamentEdit.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelTeams,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelClub.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSanctions,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSanction.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSanctionEdit.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelVenues,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelVenue.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSeasons,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSeason.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelTournamentWizard,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelDivisionCreate,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelDivisionEdit.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelDivision.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelMatch.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelBlog,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelBlogCreate,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelBlogEdit.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelUsers,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelUserCreate,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelUserInvite,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelUserEdit.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelUser.pattern,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelSettings,
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelChangePassword,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelEditProfile,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelStatistics,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelAuditLogs,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ {
+ path: APP_ROUTES.panelDataAdministration,
+ allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
+ element: ,
+ },
+ { path: '*', element: },
+];
+
+interface PublicRouteConfig {
+ path: string;
+ element: ReactElement;
+}
+
+const PUBLIC_ROUTES: PublicRouteConfig[] = [
+ { path: APP_ROUTES.passwordReset, element: },
+ { path: APP_ROUTES.home, element: },
+ { path: APP_ROUTES.quienesSomos, element: },
+ { path: APP_ROUTES.fichaMedica, element: },
+ { path: APP_ROUTES.reglamento, element: },
+ { path: APP_ROUTES.publicTeam.pattern, element: },
+ { path: APP_ROUTES.publicSanctions, element: },
+ { path: APP_ROUTES.publicChampions, element: },
+ { path: APP_ROUTES.publicMatch.pattern, element: },
+ { path: APP_ROUTES.publicSeasons, element: },
+ { path: APP_ROUTES.publicSeason.pattern, element: },
+ // 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: ,
+ },
+ { path: APP_ROUTES.publicBlog, element: },
+ { path: APP_ROUTES.blogPost.pattern, element: },
+];
+
+function App() {
+ const { isAuthenticated, role } = useAuth();
+ const location = useLocation();
+
+ if (location.pathname === APP_ROUTES.forbidden) return ;
+ if (location.pathname === routes.tokenInvalido) return ;
+
+ 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 ) so the sidebar survives panel navigation.
+ return (
+ <>
+
+
+ }>
+
+ }>
+ {PUBLIC_ROUTES.map(({ path, element }) => (
+
+ ))}
+
+ } />
+ } />
+ } />
+
+ {isAuthenticated && (
+
+
+
+ }
+ >
+ {ADMIN_ROUTES.filter(({ path }) => path !== '*').map(
+ ({ path, element, allowedRoles }) => (
+
+ {element}
+
+ ) : (
+ element
+ )
+ }
+ />
+ )
+ )}
+ }
+ />
+
+ )}
+
+ } />
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/Club12-WebClient/src/design/categoryColor.test.ts b/Club12-WebClient/src/design/categoryColor.test.ts
new file mode 100644
index 0000000..3808430
--- /dev/null
+++ b/Club12-WebClient/src/design/categoryColor.test.ts
@@ -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');
+ });
+});
diff --git a/Club12-WebClient/src/design/categoryColor.ts b/Club12-WebClient/src/design/categoryColor.ts
new file mode 100644
index 0000000..892492f
--- /dev/null
+++ b/Club12-WebClient/src/design/categoryColor.ts
@@ -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',
+ };
+};
diff --git a/Club12-WebClient/src/design/colorName.test.ts b/Club12-WebClient/src/design/colorName.test.ts
new file mode 100644
index 0000000..dd0bba4
--- /dev/null
+++ b/Club12-WebClient/src/design/colorName.test.ts
@@ -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);
+ });
+});
diff --git a/Club12-WebClient/src/design/colorName.ts b/Club12-WebClient/src/design/colorName.ts
new file mode 100644
index 0000000..97a0761
--- /dev/null
+++ b/Club12-WebClient/src/design/colorName.ts
@@ -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 };
+};
diff --git a/Club12-WebClient/src/design/jerseyStyles.test.ts b/Club12-WebClient/src/design/jerseyStyles.test.ts
new file mode 100644
index 0000000..d3ec20b
--- /dev/null
+++ b/Club12-WebClient/src/design/jerseyStyles.test.ts
@@ -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);
+ });
+});
diff --git a/Club12-WebClient/src/design/jerseyStyles.ts b/Club12-WebClient/src/design/jerseyStyles.ts
new file mode 100644
index 0000000..91e1063
--- /dev/null
+++ b/Club12-WebClient/src/design/jerseyStyles.ts
@@ -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;
diff --git a/Club12-WebClient/src/design/tokens.ts b/Club12-WebClient/src/design/tokens.ts
new file mode 100644
index 0000000..e4bea5e
--- /dev/null
+++ b/Club12-WebClient/src/design/tokens.ts
@@ -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)';
diff --git a/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts b/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts
new file mode 100644
index 0000000..2ba8007
--- /dev/null
+++ b/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts
@@ -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';
+}
diff --git a/Club12-WebClient/src/index.css b/Club12-WebClient/src/index.css
new file mode 100644
index 0000000..7498493
--- /dev/null
+++ b/Club12-WebClient/src/index.css
@@ -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;
+}
\ No newline at end of file
diff --git a/Club12-WebClient/src/main.tsx b/Club12-WebClient/src/main.tsx
new file mode 100644
index 0000000..929be85
--- /dev/null
+++ b/Club12-WebClient/src/main.tsx
@@ -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(
+
+
+
+
+
+);
diff --git a/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx b/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx
new file mode 100644
index 0000000..44a792e
--- /dev/null
+++ b/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx
@@ -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(
+ undefined
+);
+
+export const AuditLogProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const queryClient = useQueryClient();
+ const handleUnknownError = useUnknownErrorHandler();
+
+ const getAuditLogs = useCallback(
+ async (
+ filter: AuditLogFiltered
+ ): Promise | 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 (
+
+ {children}
+
+ );
+};
diff --git a/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts b/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts
new file mode 100644
index 0000000..79692b5
--- /dev/null
+++ b/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/auditLog/queryKeys.ts b/Club12-WebClient/src/modules/auditLog/queryKeys.ts
new file mode 100644
index 0000000..8bf268b
--- /dev/null
+++ b/Club12-WebClient/src/modules/auditLog/queryKeys.ts
@@ -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),
+};
diff --git a/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts b/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts
new file mode 100644
index 0000000..78ca1dd
--- /dev/null
+++ b/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts
@@ -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>>}
+ */
+ getAuditLogs: async (
+ filter: AuditLogFiltered
+ ): Promise>> =>
+ sendGet>(
+ routes.auditLogs,
+ withTablePageSize(filter)
+ ),
+};
diff --git a/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts b/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts
new file mode 100644
index 0000000..7e46112
--- /dev/null
+++ b/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts
@@ -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 | void>;
+}
diff --git a/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx b/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx
new file mode 100644
index 0000000..68725db
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx
@@ -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 }) => (
+
+
+ {children}
+
+
+);
+
+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,
+ })
+ );
+ });
+});
diff --git a/Club12-WebClient/src/modules/auth/context/auth.context.tsx b/Club12-WebClient/src/modules/auth/context/auth.context.tsx
new file mode 100644
index 0000000..73cf3ac
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/context/auth.context.tsx
@@ -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(
+ 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 = {
+ 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>(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 = ({ 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(() => {
+ 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(Cookies.get(COOKIE_SIGNIN_TOKEN))
+ );
+ const authTimeoutRef = useRef | 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 => {
+ 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 => {
+ 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 (
+ {children}
+ );
+};
diff --git a/Club12-WebClient/src/modules/auth/hook/auth.hook.ts b/Club12-WebClient/src/modules/auth/hook/auth.hook.ts
new file mode 100644
index 0000000..8cbe4d2
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/hook/auth.hook.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/auth/queryKeys.test.ts b/Club12-WebClient/src/modules/auth/queryKeys.test.ts
new file mode 100644
index 0000000..15d5499
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/queryKeys.test.ts
@@ -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']);
+ });
+});
diff --git a/Club12-WebClient/src/modules/auth/queryKeys.ts b/Club12-WebClient/src/modules/auth/queryKeys.ts
new file mode 100644
index 0000000..f0b1c02
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/queryKeys.ts
@@ -0,0 +1,3 @@
+export const authKeys = {
+ hasToken: () => ['auth', 'has-token'] as const,
+};
diff --git a/Club12-WebClient/src/modules/auth/service/auth.service.test.ts b/Club12-WebClient/src/modules/auth/service/auth.service.test.ts
new file mode 100644
index 0000000..807d8f3
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/service/auth.service.test.ts
@@ -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',
+ });
+ });
+});
diff --git a/Club12-WebClient/src/modules/auth/service/auth.service.ts b/Club12-WebClient/src/modules/auth/service/auth.service.ts
new file mode 100644
index 0000000..dbe951c
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/service/auth.service.ts
@@ -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 | undefined> =>
+ sendPost(`${routes.auth}/login`, user),
+
+ refreshTokenRequest: (
+ refreshToken: RefreshTokenRequest
+ ): Promise | undefined> =>
+ sendPost(`${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 | undefined> =>
+ sendPost(`${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 | undefined> =>
+ sendPost(`${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 | undefined> =>
+ sendPost(`${routes.auth}/password-reset/request`, payload),
+
+ confirmPasswordResetRequest: (
+ payload: PasswordResetConfirmRequest
+ ): Promise | undefined> =>
+ sendPost(`${routes.auth}/password-reset/confirm`, payload),
+
+ logoutRequest: () => sendPost(`${routes.auth}/logout`),
+};
diff --git a/Club12-WebClient/src/modules/auth/type/auth.d.ts b/Club12-WebClient/src/modules/auth/type/auth.d.ts
new file mode 100644
index 0000000..f49a58c
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/type/auth.d.ts
@@ -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} Whether authentication was successful.
+ */
+ signIn: (value: LogInUserRequest) => Promise;
+
+ /**
+ * Log out the current user and clear authentication state.
+ * @returns {Promise} Returns a promise that resolves when logout is complete.
+ */
+ logOut: () => Promise;
+
+ /**
+ * 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;
+}
diff --git a/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts b/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts
new file mode 100644
index 0000000..b9377dd
--- /dev/null
+++ b/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts
@@ -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' },
+];
diff --git a/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts b/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts
new file mode 100644
index 0000000..6b01fef
--- /dev/null
+++ b/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts
@@ -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 => ({
+ id: 'guid-1-aaaa-bbbb-cccc',
+ createdAt: '2026-08-19T10:00:00Z',
+ sizeBytes: 1024,
+ origin: 'Manual',
+ storagePath: 'backup-1.sql',
+ ...overrides,
+});
+
+const buildResponse = (data: T, status = 200): AxiosResponse =>
+ ({
+ data,
+ status,
+ statusText: 'OK',
+ headers: {},
+ config: {},
+ }) as AxiosResponse;
+
+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) => 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) => 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;
+ 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;
+ 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([]);
+ });
+});
diff --git a/Club12-WebClient/src/modules/backup/hook/backup.hook.ts b/Club12-WebClient/src/modules/backup/hook/backup.hook.ts
new file mode 100644
index 0000000..042a74c
--- /dev/null
+++ b/Club12-WebClient/src/modules/backup/hook/backup.hook.ts
@@ -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;
+ createBackup: () => Promise;
+ deleteBackup: (id: string) => Promise;
+ restoreBackup: (id: string) => Promise;
+}
+
+/**
+ * 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([]);
+ 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 => {
+ try {
+ const response = await backupService.getBackups();
+ setBackups(response.data);
+ } catch {
+ // keep the previous list
+ }
+ }, []);
+
+ const fetchBackups = useCallback(async (): Promise => {
+ setLoading(true);
+ try {
+ await refreshCatalog();
+ } finally {
+ setLoading(false);
+ }
+ }, [refreshCatalog]);
+
+ const createBackup = useCallback(async (): Promise => {
+ 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 => {
+ 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 => {
+ 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,
+ };
+};
diff --git a/Club12-WebClient/src/modules/backup/service/backup.service.ts b/Club12-WebClient/src/modules/backup/service/backup.service.ts
new file mode 100644
index 0000000..da3954a
--- /dev/null
+++ b/Club12-WebClient/src/modules/backup/service/backup.service.ts
@@ -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>} The server response.
+ */
+ getBackups: async (): Promise> =>
+ await sendGet(routes.backups),
+
+ /**
+ * Triggers an on-demand (manual) backup.
+ * @returns {Promise>} The server response containing the new backup.
+ */
+ createBackup: async (): Promise> =>
+ await sendPost(routes.backups),
+
+ /**
+ * Deletes a catalogued backup by its ID.
+ * @param {string} id - The ID of the backup to delete.
+ * @returns {Promise>} The server response.
+ */
+ deleteBackup: async (id: string): Promise> =>
+ 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>} The server response containing the safety backup.
+ */
+ restoreBackup: async (
+ id: string
+ ): Promise> =>
+ await sendPost(`${routes.backups}/${id}/restore`),
+
+ /**
+ * Force-exits maintenance mode, in case it is stuck active.
+ * @returns {Promise>} The server response.
+ */
+ exitMaintenance: async (): Promise> =>
+ await sendDelete(routes.maintenance),
+};
diff --git a/Club12-WebClient/src/modules/backup/type/backup.d.ts b/Club12-WebClient/src/modules/backup/type/backup.d.ts
new file mode 100644
index 0000000..90878d7
--- /dev/null
+++ b/Club12-WebClient/src/modules/backup/type/backup.d.ts
@@ -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;
+}
diff --git a/Club12-WebClient/src/modules/backup/utils/backupFormat.ts b/Club12-WebClient/src/modules/backup/utils/backupFormat.ts
new file mode 100644
index 0000000..c58bc4c
--- /dev/null
+++ b/Club12-WebClient/src/modules/backup/utils/backupFormat.ts
@@ -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]}`;
+};
diff --git a/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts b/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts
new file mode 100644
index 0000000..444bfd0
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts
@@ -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;
diff --git a/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx
new file mode 100644
index 0000000..820f197
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx
@@ -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 }) => (
+
+
+ {children}
+
+
+);
+
+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();
+ });
+});
diff --git a/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx
new file mode 100644
index 0000000..3770bfa
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx
@@ -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(
+ 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 => {
+ try {
+ const response: AxiosResponse =
+ 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 => {
+ 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 => {
+ 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 =
+ 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 => {
+ 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 | 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 => {
+ 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 (
+
+ {children}
+
+ );
+};
diff --git a/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts b/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts
new file mode 100644
index 0000000..2c84edf
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts b/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts
new file mode 100644
index 0000000..04aeb5f
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts
@@ -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]);
+ });
+});
diff --git a/Club12-WebClient/src/modules/blogPost/queryKeys.ts b/Club12-WebClient/src/modules/blogPost/queryKeys.ts
new file mode 100644
index 0000000..aa6b58e
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/queryKeys.ts
@@ -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,
+};
diff --git a/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts b/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts
new file mode 100644
index 0000000..37919eb
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts
@@ -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>} - A promise that resolves with the server response.
+ */
+ addBlogPost: (
+ post: CreateBlogPostRequest
+ ): Promise> => {
+ 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(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>} - A promise that resolves with the server response.
+ */
+ putBlogPostById: async (
+ id: GUID,
+ post: UpdateBlogPostRequest
+ ): Promise> => {
+ 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(`${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>} - A promise that resolves with the server response.
+ */
+ putPhotoBlogPostById: async (
+ id: GUID,
+ photo: File
+ ): Promise> => {
+ const formData = new FormData();
+ formData.append('PhotoFile', photo);
+
+ return sendPut(`${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>} - A promise that resolves with the blog post data.
+ */
+ getBlogPostsById: async (
+ idOrSlug: string
+ ): Promise> =>
+ sendGet(`${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>> =>
+ sendGet>(
+ routes.blogposts,
+ withTablePageSize(filter)
+ ),
+
+ /**
+ * Deletes a blog post by its ID.
+ * @param {string} id - The ID of the blog post to delete.
+ * @returns {Promise>} - A promise that resolves when the blog post is deleted.
+ */
+ deleteBlogPostById: async (id: GUID): Promise> =>
+ sendDelete(`${routes.blogposts}/${id}`),
+};
diff --git a/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts b/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts
new file mode 100644
index 0000000..159f593
--- /dev/null
+++ b/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts
@@ -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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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 | 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;
+}
+
+/**
+ * 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;
+}
diff --git a/Club12-WebClient/src/modules/champion/service/champion.service.ts b/Club12-WebClient/src/modules/champion/service/champion.service.ts
new file mode 100644
index 0000000..223936c
--- /dev/null
+++ b/Club12-WebClient/src/modules/champion/service/champion.service.ts
@@ -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>} The server response.
+ */
+ getTournamentChampions: async (
+ idOrSlug: string
+ ): Promise> =>
+ 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>} The server response.
+ */
+ getChampionsHistory: async (
+ seasonId?: GUID
+ ): Promise> =>
+ sendGet(routes.champions, seasonId ? { seasonId } : undefined),
+};
diff --git a/Club12-WebClient/src/modules/champion/type/champion.d.ts b/Club12-WebClient/src/modules/champion/type/champion.d.ts
new file mode 100644
index 0000000..881aeb7
--- /dev/null
+++ b/Club12-WebClient/src/modules/champion/type/champion.d.ts
@@ -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;
+}
diff --git a/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts b/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts
new file mode 100644
index 0000000..d303448
--- /dev/null
+++ b/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts
@@ -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 => ({
+ 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']);
+ });
+});
diff --git a/Club12-WebClient/src/modules/champion/utils/groupChampions.ts b/Club12-WebClient/src/modules/champion/utils/groupChampions.ts
new file mode 100644
index 0000000..4a23611
--- /dev/null
+++ b/Club12-WebClient/src/modules/champion/utils/groupChampions.ts
@@ -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();
+
+ 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);
+ });
+};
diff --git a/Club12-WebClient/src/modules/club/context/club.context.tsx b/Club12-WebClient/src/modules/club/context/club.context.tsx
new file mode 100644
index 0000000..9fb8946
--- /dev/null
+++ b/Club12-WebClient/src/modules/club/context/club.context.tsx
@@ -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(
+ undefined
+);
+
+export const ClubProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [club, setClub] = useState(null);
+ const [allClubs, setAllClubs] = useState([]);
+
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 =
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ 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 (
+ {children}
+ );
+};
diff --git a/Club12-WebClient/src/modules/club/hook/club.hook.ts b/Club12-WebClient/src/modules/club/hook/club.hook.ts
new file mode 100644
index 0000000..e0270f0
--- /dev/null
+++ b/Club12-WebClient/src/modules/club/hook/club.hook.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/club/queryKeys.ts b/Club12-WebClient/src/modules/club/queryKeys.ts
new file mode 100644
index 0000000..14d0763
--- /dev/null
+++ b/Club12-WebClient/src/modules/club/queryKeys.ts
@@ -0,0 +1,4 @@
+export const clubKeys = {
+ history: (idOrSlug: string) => ['club', 'history', idOrSlug] as const,
+ all: () => ['club', 'all'] as const,
+};
diff --git a/Club12-WebClient/src/modules/club/service/club.service.ts b/Club12-WebClient/src/modules/club/service/club.service.ts
new file mode 100644
index 0000000..a19088e
--- /dev/null
+++ b/Club12-WebClient/src/modules/club/service/club.service.ts
@@ -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>} The server response.
+ */
+ getClubHistory: async (
+ idOrSlug: string
+ ): Promise> =>
+ 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>} The server response.
+ */
+ copyRoster: async (
+ targetTeamId: GUID,
+ request: IRosterCopyRequest
+ ): Promise> =>
+ await sendPost(`${routes.teams}/${targetTeamId}/roster/copy`, request),
+
+ /**
+ * Retrieves every club's stable identity summary.
+ * @returns {Promise>} The server response.
+ */
+ getAllClubs: async (): Promise> =>
+ 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>} The server response.
+ */
+ linkClubParent: async (
+ childClubId: GUID,
+ parentClubId: GUID
+ ): Promise> =>
+ 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>} The server response.
+ */
+ unlinkClubParent: async (
+ childClubId: GUID
+ ): Promise> =>
+ 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>} The server response.
+ */
+ renameClub: async (
+ clubId: GUID,
+ name: string
+ ): Promise> =>
+ await sendPut(`${routes.clubs}/${clubId}`, { name }),
+
+ /**
+ * Deletes a club.
+ * @param {GUID} clubId - The club to delete.
+ * @returns {Promise>} The server response.
+ */
+ deleteClub: async (clubId: GUID): Promise> =>
+ await sendDelete(`${routes.clubs}/${clubId}`),
+};
diff --git a/Club12-WebClient/src/modules/club/type/club.d.ts b/Club12-WebClient/src/modules/club/type/club.d.ts
new file mode 100644
index 0000000..b493e11
--- /dev/null
+++ b/Club12-WebClient/src/modules/club/type/club.d.ts
@@ -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;
+
+ /**
+ * 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;
+
+ /** Every club's stable identity summary, for the "link to parent club" picker. */
+ allClubs: IClubSummaryResponse[];
+
+ /** Fetches every club's stable identity summary. */
+ getAllClubs(): Promise;
+
+ /**
+ * 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;
+
+ /**
+ * Clears a club's parent institution link, if any.
+ * @param childClubId The club to unlink.
+ */
+ unlinkClubParent(childClubId: GUID): Promise;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+}
diff --git a/Club12-WebClient/src/modules/core/constants/appRoutes.ts b/Club12-WebClient/src/modules/core/constants/appRoutes.ts
new file mode 100644
index 0000000..823caac
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/appRoutes.ts
@@ -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;
diff --git a/Club12-WebClient/src/modules/core/constants/constants.ts b/Club12-WebClient/src/modules/core/constants/constants.ts
new file mode 100644
index 0000000..fe914d3
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/constants.ts
@@ -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;
diff --git a/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts b/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts
new file mode 100644
index 0000000..a4ba91f
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts
@@ -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,
+});
diff --git a/Club12-WebClient/src/modules/core/constants/httpStatus.ts b/Club12-WebClient/src/modules/core/constants/httpStatus.ts
new file mode 100644
index 0000000..e411d76
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/httpStatus.ts
@@ -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;
diff --git a/Club12-WebClient/src/modules/core/constants/order.ts b/Club12-WebClient/src/modules/core/constants/order.ts
new file mode 100644
index 0000000..492d966
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/order.ts
@@ -0,0 +1,4 @@
+export enum Order {
+ ASC = 0,
+ DESC = 1,
+}
diff --git a/Club12-WebClient/src/modules/core/constants/pagination.ts b/Club12-WebClient/src/modules/core/constants/pagination.ts
new file mode 100644
index 0000000..3170ac7
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/pagination.ts
@@ -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 = (
+ filter: T
+): T & { pageSize: number } => ({
+ ...filter,
+ pageSize: filter.pageSize ?? TABLE_ROWS_PER_PAGE,
+});
diff --git a/Club12-WebClient/src/modules/core/constants/routes.test.ts b/Club12-WebClient/src/modules/core/constants/routes.test.ts
new file mode 100644
index 0000000..9d58e77
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/routes.test.ts
@@ -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');
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/constants/routes.ts b/Club12-WebClient/src/modules/core/constants/routes.ts
new file mode 100644
index 0000000..981da42
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/constants/routes.ts
@@ -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;
diff --git a/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts b/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts
new file mode 100644
index 0000000..b67bd9b
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts
@@ -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',
+}
diff --git a/Club12-WebClient/src/modules/core/enum/match/matchType.ts b/Club12-WebClient/src/modules/core/enum/match/matchType.ts
new file mode 100644
index 0000000..acc0820
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/match/matchType.ts
@@ -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',
+}
diff --git a/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts b/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts
new file mode 100644
index 0000000..9513b9c
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts
@@ -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',
+}
diff --git a/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts b/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts
new file mode 100644
index 0000000..d9379b6
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts
@@ -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.Masculine]: 'Masculino',
+ [TournamentCategory.Feminine]: 'Femenino',
+};
diff --git a/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts b/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts
new file mode 100644
index 0000000..ea58e8b
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts
@@ -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];
diff --git a/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts b/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts
new file mode 100644
index 0000000..733026b
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts
@@ -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.Admin]: 'Admin',
+ [UserRolesType.Owner]: 'Owner',
+ [UserRolesType.Guest]: 'Invitado',
+};
diff --git a/Club12-WebClient/src/modules/core/types/types.d.ts b/Club12-WebClient/src/modules/core/types/types.d.ts
new file mode 100644
index 0000000..66b8957
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/types/types.d.ts
@@ -0,0 +1,43 @@
+import { ReactNode } from 'react';
+import { Order } from '@/modules/core/constants/order';
+export interface ProviderProps {
+ children: ReactNode;
+}
+
+export interface GenericResponsePagination {
+ 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}`;
diff --git a/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts b/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts
new file mode 100644
index 0000000..54b5786
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts
@@ -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 => {
+ 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();
+ 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);
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/axiosUtils.ts b/Club12-WebClient/src/modules/core/utils/axiosUtils.ts
new file mode 100644
index 0000000..a02d10e
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/axiosUtils.ts
@@ -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 | 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>} A promise that resolves with the response or undefined.
+ */
+const sendRequest = async (
+ method: string,
+ resource: string,
+ configOverride: object = {},
+ body: unknown | null = null,
+ query?: object
+): Promise> => {
+ 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 = 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>} A promise that resolves with the server response.
+ */
+export const sendPost = async (
+ resource: string,
+ body?: unknown,
+ configOverride?: ConfigOverride
+): Promise> => {
+ return await sendRequest('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>} A promise that resolves with the server response.
+ */
+export const sendPut = async (
+ resource: string,
+ body: unknown,
+ configOverride?: ConfigOverride
+): Promise> => {
+ return await sendRequest('PUT', resource, configOverride, body);
+};
+
+/**
+ * Sends a GET HTTP request.
+ * @param {string} resource - API resource.
+ * @param {object} [query] - Query parameters.
+ * @returns {Promise>} A promise that resolves with the server response.
+ */
+export const sendGet = async (
+ resource: string,
+ query?: object
+): Promise> => {
+ return await sendRequest('GET', resource, {}, null, query);
+};
+
+/**
+ * Sends a DELETE HTTP request.
+ * @param {string} resource - API resource.
+ * @param {ConfigOverride} [configOverride] - Configuration overrides.
+ * @returns {Promise>} A promise that resolves when the resource is deleted.
+ */
+export const sendDelete = async (
+ resource: string,
+ configOverride?: ConfigOverride,
+ body?: unknown
+): Promise> =>
+ await sendRequest('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);
+};
diff --git a/Club12-WebClient/src/modules/core/utils/comparator.ts b/Club12-WebClient/src/modules/core/utils/comparator.ts
new file mode 100644
index 0000000..ffc047f
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/comparator.ts
@@ -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>>} apiCall
+ * @param {T[] | null} currentState
+ * @param {React.Dispatch>} setState
+ * @param {F} filter
+ * @returns {Promise | void>}
+ */
+export async function fetchAndSetList(options: {
+ apiCall: (filter: F) => Promise>>;
+ currentState: T[] | null;
+ setState: React.Dispatch>;
+ filter: F;
+}): Promise | void> {
+ const { apiCall, currentState, setState, filter } = options;
+
+ const res: AxiosResponse> =
+ 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;
+}
diff --git a/Club12-WebClient/src/modules/core/utils/confirmDialog.ts b/Club12-WebClient/src/modules/core/utils/confirmDialog.ts
new file mode 100644
index 0000000..fd22882
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/confirmDialog.ts
@@ -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 {
+ 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 =>
+ 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 =>
+ 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 =>
+ 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 =>
+ 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 {
+ 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 =>
+ confirmAction({ ...options, confirmButtonText: options.confirmButtonText ?? 'Sí, eliminar' });
diff --git a/Club12-WebClient/src/modules/core/utils/csv.test.ts b/Club12-WebClient/src/modules/core/utils/csv.test.ts
new file mode 100644
index 0000000..644238c
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/csv.test.ts
@@ -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"']]);
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/csv.ts b/Club12-WebClient/src/modules/core/utils/csv.ts
new file mode 100644
index 0000000..8d0c725
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/csv.ts
@@ -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 `.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),
+ };
+};
diff --git a/Club12-WebClient/src/modules/core/utils/formUtils.ts b/Club12-WebClient/src/modules/core/utils/formUtils.ts
new file mode 100644
index 0000000..af5cbe3
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/formUtils.ts
@@ -0,0 +1,5 @@
+export function handleFields(event: React.FormEvent) {
+ event.preventDefault();
+ const fields = Object.fromEntries(new window.FormData(event.currentTarget));
+ return fields;
+}
diff --git a/Club12-WebClient/src/modules/core/utils/formatDate.test.ts b/Club12-WebClient/src/modules/core/utils/formatDate.test.ts
new file mode 100644
index 0000000..26e910d
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/formatDate.test.ts
@@ -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 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('—');
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/formatDate.ts b/Club12-WebClient/src/modules/core/utils/formatDate.ts
new file mode 100644
index 0000000..56b5f4a
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/formatDate.ts
@@ -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 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
+ * 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;
+};
diff --git a/Club12-WebClient/src/modules/core/utils/geocoding.test.ts b/Club12-WebClient/src/modules/core/utils/geocoding.test.ts
new file mode 100644
index 0000000..468a7ff
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/geocoding.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { geocodeAddress } from './geocoding';
+
+const mockFetch = (response: Partial & { 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();
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/geocoding.ts b/Club12-WebClient/src/modules/core/utils/geocoding.ts
new file mode 100644
index 0000000..5a43b2c
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/geocoding.ts
@@ -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;
+ }
+};
diff --git a/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts
new file mode 100644
index 0000000..6b508e5
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts
@@ -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();
+ 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();
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts
new file mode 100644
index 0000000..91d3819
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts
@@ -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();
+
+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();
+});
diff --git a/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts b/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts
new file mode 100644
index 0000000..b94ebfe
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts
@@ -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(`meta[${attribute}="${key}"]`)
+ ?.getAttribute('content');
+
+const canonicalHref = () =>
+ document.head
+ .querySelector('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');
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/pageMetadata.ts b/Club12-WebClient/src/modules/core/utils/pageMetadata.ts
new file mode 100644
index 0000000..226728f
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/pageMetadata.ts
@@ -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(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(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]);
+};
diff --git a/Club12-WebClient/src/modules/core/utils/printStyles.ts b/Club12-WebClient/src/modules/core/utils/printStyles.ts
new file mode 100644
index 0000000..f954068
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/printStyles.ts
@@ -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
+ * `` 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',
+ },
+ },
+};
diff --git a/Club12-WebClient/src/modules/core/utils/problemDetails.ts b/Club12-WebClient/src/modules/core/utils/problemDetails.ts
new file mode 100644
index 0000000..2abeee8
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/problemDetails.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts b/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts
new file mode 100644
index 0000000..fbbce90
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts
@@ -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();
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/requestActivity.ts b/Club12-WebClient/src/modules/core/utils/requestActivity.ts
new file mode 100644
index 0000000..a5f69bc
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/requestActivity.ts
@@ -0,0 +1,87 @@
+type Listener = (activeCount: number) => void;
+
+let activeCount = 0;
+const listeners = new Set();
+
+// 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 (
+ message: string,
+ operation: () => Promise
+): Promise => {
+ const id = setBlockingMessage(message);
+ try {
+ return await operation();
+ } finally {
+ clearBlockingMessage(id);
+ }
+};
+
+export const subscribeToRequestActivity = (listener: Listener): (() => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
diff --git a/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts b/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts
new file mode 100644
index 0000000..057f93b
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts
@@ -0,0 +1,17 @@
+import { GUID } from '@/modules/core/types/types';
+
+export function upsertListById(
+ 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];
+ }
+}
diff --git a/Club12-WebClient/src/modules/core/utils/translateStageType.ts b/Club12-WebClient/src/modules/core/utils/translateStageType.ts
new file mode 100644
index 0000000..5315d3e
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/translateStageType.ts
@@ -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;
+
+/**
+ * Translates a StageType enum value into its Spanish equivalent.
+ */
+export const translateStageType = (stageType: StageType): string =>
+ STAGE_TYPE_ES[stageType];
diff --git a/Club12-WebClient/src/modules/core/utils/validators.test.ts b/Club12-WebClient/src/modules/core/utils/validators.test.ts
new file mode 100644
index 0000000..fcae785
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/validators.test.ts
@@ -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);
+ });
+});
diff --git a/Club12-WebClient/src/modules/core/utils/validators.ts b/Club12-WebClient/src/modules/core/utils/validators.ts
new file mode 100644
index 0000000..112a60a
--- /dev/null
+++ b/Club12-WebClient/src/modules/core/utils/validators.ts
@@ -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 `` 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;
diff --git a/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts b/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts
new file mode 100644
index 0000000..d4ffcf0
--- /dev/null
+++ b/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts
@@ -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>} Row counts removed.
+ */
+ wipeSampleData: async (): Promise> =>
+ await sendPost(`${routes.dataMaintenance}/wipe`),
+
+ /**
+ * Seeds 2 complete sample tournaments. Rejects with a 409 response if
+ * the database already has tournament data.
+ * @returns {Promise>} Row counts created.
+ */
+ seedSampleData: async (): Promise> =>
+ await sendPost(`${routes.dataMaintenance}/seed`),
+};
diff --git a/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts b/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts
new file mode 100644
index 0000000..32257b2
--- /dev/null
+++ b/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts
@@ -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;
+}
diff --git a/Club12-WebClient/src/modules/division/context/division.context.test.tsx b/Club12-WebClient/src/modules/division/context/division.context.test.tsx
new file mode 100644
index 0000000..1d5ec09
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/context/division.context.test.tsx
@@ -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 }) => (
+
+
+ {children}
+
+
+);
+
+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();
+ });
+});
diff --git a/Club12-WebClient/src/modules/division/context/division.context.tsx b/Club12-WebClient/src/modules/division/context/division.context.tsx
new file mode 100644
index 0000000..3566471
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/context/division.context.tsx
@@ -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(
+ undefined
+);
+
+export const DivisionProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [division, setDivision] = useState(null);
+ const [divisions, setDivisions] = useState(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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ 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 => {
+ try {
+ const res: AxiosResponse =
+ 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 => {
+ 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 =
+ 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 | 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ try {
+ await autoDistributeMutation.mutateAsync(divisionId);
+ return true;
+ } catch (error: unknown) {
+ handleUnknownError(error);
+ }
+ },
+ [autoDistributeMutation, handleUnknownError]
+ );
+
+ const rebuildSubGroups = useCallback(
+ async (divisionId: GUID, subGroupCount: number): Promise => {
+ 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 => {
+ 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 (
+
+ {children}
+
+ );
+};
diff --git a/Club12-WebClient/src/modules/division/hook/division.hook.ts b/Club12-WebClient/src/modules/division/hook/division.hook.ts
new file mode 100644
index 0000000..c657525
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/hook/division.hook.ts
@@ -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;
+};
diff --git a/Club12-WebClient/src/modules/division/queryKeys.test.ts b/Club12-WebClient/src/modules/division/queryKeys.test.ts
new file mode 100644
index 0000000..40e74aa
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/queryKeys.test.ts
@@ -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]);
+ });
+});
diff --git a/Club12-WebClient/src/modules/division/queryKeys.ts b/Club12-WebClient/src/modules/division/queryKeys.ts
new file mode 100644
index 0000000..216497a
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/queryKeys.ts
@@ -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,
+};
diff --git a/Club12-WebClient/src/modules/division/service/division.service.ts b/Club12-WebClient/src/modules/division/service/division.service.ts
new file mode 100644
index 0000000..aa6c125
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/service/division.service.ts
@@ -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>} - A promise that resolves with the server response.
+ */
+ addDivision: async (
+ division: AddDivisionRequest
+ ): Promise> =>
+ sendPost(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>} - A promise that resolves with the server response.
+ */
+ generateFixtureByDivisionId: async (id: GUID): Promise> =>
+ sendPost(`${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>} - A promise that resolves with the server response.
+ */
+ putDivisionById: async (
+ id: GUID,
+ division: IPutDivisionRequest
+ ): Promise> =>
+ sendPut(`${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>} - A promise that resolves with the division data.
+ */
+ getDivisionsById: async (
+ idOrSlug: string
+ ): Promise> =>
+ sendGet(`${routes.divisions}/${idOrSlug}/detail`),
+
+ /**
+ * Retrieves divisions based on provided filters.
+ * @param {DivisionFiltered} filter - The filters to apply when retrieving divisions.
+ * @returns {Promise>} - A promise that resolves with a list of divisions matching the filter.
+ */
+ getDivisionsByFilters: async (
+ filter: DivisionFiltered
+ ): Promise>> =>
+ sendGet>(
+ routes.divisions,
+ withTablePageSize(filter)
+ ),
+
+ /**
+ * Deletes a division by its ID.
+ * @param {string} id - The ID of the division to delete.
+ * @returns {Promise>} - A promise that resolves when the division is deleted.
+ */
+ deleteDivisionsById: async (id: GUID): Promise> =>
+ sendDelete(`${routes.divisions}/${id}`),
+
+ /**
+ * Fetches every team enrolled in a division's roster.
+ * @param {GUID} divisionId - The division whose roster to fetch.
+ * @returns {Promise>} - The enrolled teams.
+ */
+ getRoster: async (divisionId: GUID): Promise> =>
+ sendGet(`${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>} - The division's roster as it stands after enrolling (200).
+ */
+ enrollTeams: async (
+ divisionId: GUID,
+ teamIds: GUID[]
+ ): Promise> =>
+ sendPost(`${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>} - The response confirming removal.
+ */
+ unenrollTeams: async (
+ divisionId: GUID,
+ teamIds: GUID[]
+ ): Promise> =>
+ sendDelete(`${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>} - The response confirming the redistribution.
+ */
+ autoDistribute: async (divisionId: GUID): Promise> =>
+ sendPost(`${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>} - The newly-built sub-group stages (200).
+ */
+ rebuildSubGroups: async (
+ divisionId: GUID,
+ subGroupCount: number
+ ): Promise> =>
+ sendPost(`${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>} - The response confirming the move.
+ */
+ reassignTeamToSubGroup: async (
+ divisionId: GUID,
+ teamId: GUID,
+ fromStageId: GUID,
+ toStageId: GUID
+ ): Promise> =>
+ sendPost(`${routes.divisions}/${divisionId}/sub-groups/reassign`, {
+ teamId,
+ fromStageId,
+ toStageId,
+ } satisfies ReassignTeamToSubGroupRequest),
+};
diff --git a/Club12-WebClient/src/modules/division/type/division.d.ts b/Club12-WebClient/src/modules/division/type/division.d.ts
new file mode 100644
index 0000000..b25d7b8
--- /dev/null
+++ b/Club12-WebClient/src/modules/division/type/division.d.ts
@@ -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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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 | 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;
+
+ /**
+ * 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