Sube Webclient/src
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
body{
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import App from '@/App';
|
||||||
|
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
|
||||||
|
|
||||||
|
// Mutable so a test can flip the session to authenticated. Hoisted because the
|
||||||
|
// vi.mock factory below runs before module imports.
|
||||||
|
const authState = vi.hoisted(() => ({
|
||||||
|
isAuthenticated: false,
|
||||||
|
role: 'Guest' as string,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/modules/auth/hook/auth.hook', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
isAuthenticated: authState.isAuthenticated,
|
||||||
|
role: authState.role,
|
||||||
|
signIn: vi.fn(),
|
||||||
|
logOut: vi.fn(),
|
||||||
|
user: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.role = 'Guest';
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAt = (path: string) =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('App public layout chrome', () => {
|
||||||
|
it('HU-02: renders /login without header or footer', async () => {
|
||||||
|
renderAt('/login');
|
||||||
|
|
||||||
|
// Route-level pages are React.lazy-loaded (see App.tsx), so the chunk
|
||||||
|
// resolves asynchronously behind a Suspense fallback — a synchronous
|
||||||
|
// getByText would race that and fail before the real content mounts.
|
||||||
|
expect(await screen.findByText('Administrador')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('header')).toBeNull();
|
||||||
|
expect(document.querySelector('footer')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HU-04: renders the 404 page without header or footer', async () => {
|
||||||
|
renderAt('/una-ruta-que-no-existe');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/no existe o fue movida/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('header')).toBeNull();
|
||||||
|
expect(document.querySelector('footer')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps header and footer on a normal public route', async () => {
|
||||||
|
renderAt('/quienes-somos');
|
||||||
|
|
||||||
|
expect(await screen.findByRole('contentinfo')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('header')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an authenticated admin open a public page instead of 404', async () => {
|
||||||
|
// Regression: public slug routes (tournament/blog/team/match) used to be
|
||||||
|
// omitted entirely for authenticated users, so any public URL 404'd from
|
||||||
|
// the panel catch-all without ever hitting the API. They must resolve for
|
||||||
|
// logged-in users too, under the public layout (not the admin sidebar).
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.role = UserRolesType.Admin;
|
||||||
|
|
||||||
|
renderAt('/quienes-somos');
|
||||||
|
|
||||||
|
expect(await screen.findByRole('contentinfo')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/no existe o fue movida/i)).toBeNull();
|
||||||
|
expect(document.querySelector('header')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import '@fontsource/roboto/300.css';
|
||||||
|
import '@fontsource/roboto/400.css';
|
||||||
|
import '@fontsource/roboto/500.css';
|
||||||
|
import '@fontsource/roboto/700.css';
|
||||||
|
import '@fontsource/oswald/500.css';
|
||||||
|
import '@fontsource/oswald/600.css';
|
||||||
|
import '@fontsource/oswald/700.css';
|
||||||
|
import { lazy, ReactElement, Suspense } from 'react';
|
||||||
|
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
import routes from './modules/core/constants/routes';
|
||||||
|
import { APP_ROUTES } from './modules/core/constants/appRoutes';
|
||||||
|
import { useAuth } from './modules/auth/hook/auth.hook';
|
||||||
|
import SidebarLayout from './views/core/components/SidebarLayout';
|
||||||
|
import PublicLayout from './views/core/components/PublicLayout';
|
||||||
|
import { UserRolesType } from './modules/core/enum/user/userRolesType';
|
||||||
|
import InvalidToken from './views/core/errors/invalidToken';
|
||||||
|
import Forbidden from './views/core/errors/forbidden';
|
||||||
|
import NotFound from './views/core/errors/NotFound';
|
||||||
|
import PrivateRoute from './views/core/privateRoute';
|
||||||
|
import ScrollToTop from './views/core/components/ScrollToTop';
|
||||||
|
import GlobalLoadingOverlay from './views/core/components/GlobalLoadingOverlay';
|
||||||
|
import BlockingOverlay from './views/core/components/BlockingOverlay';
|
||||||
|
|
||||||
|
// Every route-level page is loaded on demand instead of shipped in the one
|
||||||
|
// main bundle every visitor downloads on first paint — the whole admin
|
||||||
|
// panel (Jugadores, Sanciones, the tournament wizard, …) was landing in a
|
||||||
|
// public visitor's browser just to render the home page. `NotFound`,
|
||||||
|
// `Forbidden` and `InvalidToken` stay eager: `App()` can return them
|
||||||
|
// directly from an early check below, outside the <Suspense> boundary the
|
||||||
|
// <Routes> tree sits in, and they're tiny enough that splitting them buys
|
||||||
|
// nothing.
|
||||||
|
const Home = lazy(() => import('./views/home/home'));
|
||||||
|
const PublicTeamPage = lazy(() => import('./views/home/teams/PublicTeamPage'));
|
||||||
|
const PublicSanctionsPage = lazy(() => import('./views/home/sanctions/PublicSanctionsPage'));
|
||||||
|
const PublicChampionsPage = lazy(() => import('./views/home/champions/PublicChampionsPage'));
|
||||||
|
const PublicMatchPage = lazy(() => import('./views/home/matches/PublicMatchPage'));
|
||||||
|
const PublicTournamentPage = lazy(() => import('./views/home/tournaments/PublicTournamentPage'));
|
||||||
|
const PublicSeasonsPage = lazy(() => import('./views/home/seasons/PublicSeasonsPage'));
|
||||||
|
const PublicSeasonPage = lazy(() => import('./views/home/seasons/PublicSeasonPage'));
|
||||||
|
const BlogPostDetailPage = lazy(() => import('./views/blogPost/BlogPostDetailPage'));
|
||||||
|
const BlogListPage = lazy(() => import('./views/blogPost/BlogListPage'));
|
||||||
|
const AddBlogPostForm = lazy(() => import('./views/blogPost/addBlogPostForm'));
|
||||||
|
const BlogPostsPage = lazy(() => import('./views/blogPost/BlogPostsPage'));
|
||||||
|
const BlogPostEditPage = lazy(() => import('./views/blogPost/BlogPostEditPage'));
|
||||||
|
const Login = lazy(() => import('./views/auth/login'));
|
||||||
|
const HowWeAre = lazy(() => import('./views/home/howWeAre/howWeAre'));
|
||||||
|
const MedicalRecord = lazy(() => import('./views/home/information/medicalRecord'));
|
||||||
|
const Regulation = lazy(() => import('./views/home/information/regulation'));
|
||||||
|
const PlayersPage = lazy(() => import('./views/player/PlayersPage'));
|
||||||
|
const PlayerPage = lazy(() => import('./views/player/PlayerPage'));
|
||||||
|
const TeamPage = lazy(() => import('./views/team/TeamPage'));
|
||||||
|
const TournamentPage = lazy(() => import('./views/tournament/TournamentPage'));
|
||||||
|
const TournamentEditPage = lazy(() => import('./views/tournament/TournamentEditPage'));
|
||||||
|
const TournamentWizardPage = lazy(() => import('./views/tournament/wizard/TournamentWizardPage'));
|
||||||
|
const DivisionPage = lazy(() => import('./views/division/divisionPage'));
|
||||||
|
const DivisionCreatePage = lazy(() => import('./views/division/divisionCreatePage'));
|
||||||
|
const DivisionEditPage = lazy(() => import('./views/division/divisionEditPage'));
|
||||||
|
const MatchPage = lazy(() => import('./views/match/matchPage'));
|
||||||
|
const UsersPage = lazy(() => import('./views/panel/UsersPage'));
|
||||||
|
const UserDetails = lazy(() => import('./views/user/userDetails'));
|
||||||
|
const CreateUser = lazy(() => import('./views/user/createUser'));
|
||||||
|
const InviteUser = lazy(() => import('./views/user/inviteUser'));
|
||||||
|
const EditUser = lazy(() => import('./views/user/editUser'));
|
||||||
|
const ChangePasswordPage = lazy(() => import('./views/panel/ChangePasswordPage'));
|
||||||
|
const StatisticsPage = lazy(() => import('./views/panel/StatisticsPage'));
|
||||||
|
const AuditLogsPage = lazy(() => import('./views/panel/AuditLogsPage'));
|
||||||
|
const DataAdministrationPage = lazy(() => import('./views/panel/DataAdministrationPage'));
|
||||||
|
const PasswordReset = lazy(() => import('./views/auth/passwordReset'));
|
||||||
|
const ForgotPassword = lazy(() => import('./views/auth/forgotPassword'));
|
||||||
|
const ActivateAccount = lazy(() => import('./views/auth/activateAccount'));
|
||||||
|
const ClubsPage = lazy(() => import('./views/club/ClubsPage'));
|
||||||
|
const ClubHistoryPage = lazy(() => import('./views/club/ClubHistoryPage'));
|
||||||
|
const PlayerSanctionsPage = lazy(() => import('./views/playerSanction/PlayerSanctionsPage'));
|
||||||
|
const PlayerSanctionPage = lazy(() => import('./views/playerSanction/PlayerSanctionPage'));
|
||||||
|
const PlayerSanctionEditPage = lazy(() => import('./views/playerSanction/playerSanctionEditPage'));
|
||||||
|
const VenuesPage = lazy(() => import('./views/venue/VenuesPage'));
|
||||||
|
const VenuePage = lazy(() => import('./views/venue/venuePage'));
|
||||||
|
const SeasonsPage = lazy(() => import('./views/season/SeasonsPage'));
|
||||||
|
const AdminSeasonDetailPage = lazy(() => import('./views/season/AdminSeasonDetailPage'));
|
||||||
|
|
||||||
|
const FIRST_TAB_BY_ROLE: Partial<Record<UserRolesType, string>> = {
|
||||||
|
[UserRolesType.Owner]: APP_ROUTES.panelSeasons,
|
||||||
|
[UserRolesType.Admin]: APP_ROUTES.panelSeasons,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AdminRouteConfig {
|
||||||
|
path: string;
|
||||||
|
element: ReactElement;
|
||||||
|
allowedRoles?: UserRolesType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_ROUTES: AdminRouteConfig[] = [
|
||||||
|
{ path: APP_ROUTES.passwordReset, element: <PasswordReset /> },
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelPlayers,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <PlayersPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelPlayer.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <PlayerPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelTeamDetail.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <TeamPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelTournamentDetail.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <TournamentPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelTournamentEdit.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <TournamentEditPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelTeams,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <ClubsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelClub.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <ClubHistoryPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSanctions,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <PlayerSanctionsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSanction.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <PlayerSanctionPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSanctionEdit.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <PlayerSanctionEditPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelVenues,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <VenuesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelVenue.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <VenuePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSeasons,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <SeasonsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSeason.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <AdminSeasonDetailPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelTournamentWizard,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <TournamentWizardPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelDivisionCreate,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <DivisionCreatePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelDivisionEdit.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <DivisionEditPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelDivision.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <DivisionPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelMatch.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <MatchPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelBlog,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <BlogPostsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelBlogCreate,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <AddBlogPostForm />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelBlogEdit.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <BlogPostEditPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelUsers,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <UsersPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelUserCreate,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <CreateUser />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelUserInvite,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <InviteUser />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelUserEdit.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <EditUser />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelUser.pattern,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <UserDetails />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelSettings,
|
||||||
|
element: <Navigate to={APP_ROUTES.panelChangePassword} replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelChangePassword,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <ChangePasswordPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelEditProfile,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <EditUser />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelStatistics,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <StatisticsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelAuditLogs,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <AuditLogsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.panelDataAdministration,
|
||||||
|
allowedRoles: [UserRolesType.Admin, UserRolesType.Owner],
|
||||||
|
element: <DataAdministrationPage />,
|
||||||
|
},
|
||||||
|
{ path: '*', element: <NotFound /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PublicRouteConfig {
|
||||||
|
path: string;
|
||||||
|
element: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_ROUTES: PublicRouteConfig[] = [
|
||||||
|
{ path: APP_ROUTES.passwordReset, element: <PasswordReset /> },
|
||||||
|
{ path: APP_ROUTES.home, element: <Home /> },
|
||||||
|
{ path: APP_ROUTES.quienesSomos, element: <HowWeAre /> },
|
||||||
|
{ path: APP_ROUTES.fichaMedica, element: <MedicalRecord /> },
|
||||||
|
{ path: APP_ROUTES.reglamento, element: <Regulation /> },
|
||||||
|
{ path: APP_ROUTES.publicTeam.pattern, element: <PublicTeamPage /> },
|
||||||
|
{ path: APP_ROUTES.publicSanctions, element: <PublicSanctionsPage /> },
|
||||||
|
{ path: APP_ROUTES.publicChampions, element: <PublicChampionsPage /> },
|
||||||
|
{ path: APP_ROUTES.publicMatch.pattern, element: <PublicMatchPage /> },
|
||||||
|
{ path: APP_ROUTES.publicSeasons, element: <PublicSeasonsPage /> },
|
||||||
|
{ path: APP_ROUTES.publicSeason.pattern, element: <PublicSeasonPage /> },
|
||||||
|
// No `/torneos` flat listing route: it was never linked from any nav
|
||||||
|
// (dead/unreachable, HU orphan-route audit) — every tournament is reached
|
||||||
|
// via Temporadas -> season -> tournament instead.
|
||||||
|
{
|
||||||
|
path: APP_ROUTES.publicTournament.pattern,
|
||||||
|
element: <PublicTournamentPage />,
|
||||||
|
},
|
||||||
|
{ path: APP_ROUTES.publicBlog, element: <BlogListPage /> },
|
||||||
|
{ path: APP_ROUTES.blogPost.pattern, element: <BlogPostDetailPage /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated, role } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (location.pathname === APP_ROUTES.forbidden) return <Forbidden />;
|
||||||
|
if (location.pathname === routes.tokenInvalido) return <InvalidToken />;
|
||||||
|
|
||||||
|
const defaultTab = FIRST_TAB_BY_ROLE[role] ?? APP_ROUTES.panelUsers;
|
||||||
|
|
||||||
|
// Public pages render for EVERYONE — authenticated or not — so shareable
|
||||||
|
// slug links (public tournament HU-14, blog post HU-13, team/match, HU-15)
|
||||||
|
// keep working even while an admin is logged in. Previously the whole public
|
||||||
|
// route tree was omitted for authenticated users, so any public slug URL fell
|
||||||
|
// through to the panel catch-all and 404'd without ever hitting the API.
|
||||||
|
//
|
||||||
|
// Login (HU-02) and the 404/NotFound catch-all (HU-04) render without the
|
||||||
|
// public header/footer, so they sit outside the PublicLayout chrome. The
|
||||||
|
// admin panel is only mounted when authenticated, under one persistent
|
||||||
|
// SidebarLayout (via <Outlet />) so the sidebar survives panel navigation.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollToTop />
|
||||||
|
<GlobalLoadingOverlay />
|
||||||
|
<Suspense fallback={<BlockingOverlay open />}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<PublicLayout />}>
|
||||||
|
{PUBLIC_ROUTES.map(({ path, element }) => (
|
||||||
|
<Route key={path} path={path} element={element} />
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
<Route path={APP_ROUTES.login} element={<Login />} />
|
||||||
|
<Route path={APP_ROUTES.forgotPassword} element={<ForgotPassword />} />
|
||||||
|
<Route path={APP_ROUTES.activate} element={<ActivateAccount />} />
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<SidebarLayout>
|
||||||
|
<Outlet />
|
||||||
|
</SidebarLayout>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ADMIN_ROUTES.filter(({ path }) => path !== '*').map(
|
||||||
|
({ path, element, allowedRoles }) => (
|
||||||
|
<Route
|
||||||
|
key={path}
|
||||||
|
path={path}
|
||||||
|
element={
|
||||||
|
allowedRoles ? (
|
||||||
|
<PrivateRoute allowedRoles={allowedRoles}>
|
||||||
|
{element}
|
||||||
|
</PrivateRoute>
|
||||||
|
) : (
|
||||||
|
element
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Route
|
||||||
|
path={APP_ROUTES.panel}
|
||||||
|
element={<Navigate to={defaultTab} replace />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { categoryColor } from './categoryColor';
|
||||||
|
import { category } from './tokens';
|
||||||
|
|
||||||
|
describe('categoryColor', () => {
|
||||||
|
it('tints the masculine category with the brand orange', () => {
|
||||||
|
const { fill, ink } = categoryColor(TournamentCategory.Masculine);
|
||||||
|
|
||||||
|
expect(fill).toBe(category.masculine);
|
||||||
|
// Regression: white text on this orange is only ~2.86:1 (fails WCAG AA's
|
||||||
|
// 4.5:1) — dark ink is the one that actually reads on it, ~6.3:1.
|
||||||
|
expect(ink).toBe('#0b0f17');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tints the feminine category with the brand purple', () => {
|
||||||
|
const { fill, ink } = categoryColor(TournamentCategory.Feminine);
|
||||||
|
|
||||||
|
expect(fill).toBe('#A32CC4');
|
||||||
|
expect(ink).toBe('#f5f5f5');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { LIGHT_INK_LUMINANCE_THRESHOLD, luminance } from './colorName';
|
||||||
|
import { category } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source for category branding hues (masculine -> orange, feminine
|
||||||
|
* -> purple) plus the ink that stays legible on top. Chips and section accents
|
||||||
|
* read from here so the masculine/feminine visual language is defined once and
|
||||||
|
* never drifts between surfaces.
|
||||||
|
*/
|
||||||
|
export interface CategoryColor {
|
||||||
|
/** The category's brand fill (#rrggbb). */
|
||||||
|
fill: string;
|
||||||
|
/** A contrasting ink (near-black on light fills, off-white on dark ones). */
|
||||||
|
ink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a tournament category into its brand fill plus a legible ink. */
|
||||||
|
export const categoryColor = (cat: TournamentCategory): CategoryColor => {
|
||||||
|
const fill =
|
||||||
|
cat === TournamentCategory.Feminine ? category.feminine : category.masculine;
|
||||||
|
// Mirror resolveShirtColor's threshold/ink so contrast stays consistent.
|
||||||
|
return {
|
||||||
|
fill,
|
||||||
|
ink: luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD ? '#0b0f17' : '#f5f5f5',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isHexColor, luminance, resolveShirtColor } from './colorName';
|
||||||
|
import { brand } from './tokens';
|
||||||
|
|
||||||
|
describe('isHexColor', () => {
|
||||||
|
it('accepts #rgb and #rrggbb', () => {
|
||||||
|
expect(isHexColor('#f00')).toBe(true);
|
||||||
|
expect(isHexColor('#FF0000')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects names, empty, and malformed values', () => {
|
||||||
|
expect(isHexColor('rojo')).toBe(false);
|
||||||
|
expect(isHexColor('')).toBe(false);
|
||||||
|
expect(isHexColor(undefined)).toBe(false);
|
||||||
|
expect(isHexColor('#12')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('luminance', () => {
|
||||||
|
it('is near 0 for black and near 1 for white', () => {
|
||||||
|
expect(luminance('#000000')).toBeCloseTo(0, 2);
|
||||||
|
expect(luminance('#ffffff')).toBeCloseTo(1, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveShirtColor', () => {
|
||||||
|
it('keeps a valid hex and expands the short form', () => {
|
||||||
|
expect(resolveShirtColor('#FF0000').fill).toBe('#ff0000');
|
||||||
|
expect(resolveShirtColor('#fff').fill).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks dark ink on a light fill and light ink on a dark fill', () => {
|
||||||
|
const white = resolveShirtColor('#ffffff');
|
||||||
|
expect(white.isLight).toBe(true);
|
||||||
|
expect(white.ink).toBe('#0b0f17');
|
||||||
|
|
||||||
|
const navy = resolveShirtColor('#0f2e6b');
|
||||||
|
expect(navy.isLight).toBe(false);
|
||||||
|
expect(navy.ink).toBe('#f5f5f5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the navy chrome hue for non-hex or empty values', () => {
|
||||||
|
expect(resolveShirtColor('Rojo').fill).toBe(brand.navyLight);
|
||||||
|
expect(resolveShirtColor(undefined).fill).toBe(brand.navyLight);
|
||||||
|
expect(resolveShirtColor('').fill).toBe(brand.navyLight);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { brand } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team shirt colors are chosen with a color picker, so the stored value is a
|
||||||
|
* `#rrggbb` hex. This module resolves that hex into a usable fill plus the ink
|
||||||
|
* color that stays legible on top of it. Anything that is not a valid hex
|
||||||
|
* (e.g. an empty value, or legacy free-text left over from before the picker)
|
||||||
|
* falls back to the navy chrome hue so a jersey never renders with no fill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
/** Expands a 3-digit hex (#abc) to its 6-digit form (#aabbcc). */
|
||||||
|
const expandHex = (hex: string): string => {
|
||||||
|
if (hex.length === 4) {
|
||||||
|
const [, r, g, b] = hex;
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`;
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when the value is a valid `#rgb` or `#rrggbb` hex string. */
|
||||||
|
export const isHexColor = (value?: string | null): boolean =>
|
||||||
|
typeof value === 'string' && HEX_RE.test(value.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a `#rgb`/`#rrggbb` hex into an `rgba()` string at the given alpha, so a
|
||||||
|
* brand hue can be used as a translucent tint/overlay. Non-hex values fall back
|
||||||
|
* to the navy chrome hue so a surface never renders with a broken color.
|
||||||
|
*/
|
||||||
|
export const hexToRgba = (hex: string, alpha: number): string => {
|
||||||
|
const full = HEX_RE.test(hex.trim())
|
||||||
|
? expandHex(hex.trim())
|
||||||
|
: brand.navyLight;
|
||||||
|
const r = parseInt(full.slice(1, 3), 16);
|
||||||
|
const g = parseInt(full.slice(3, 5), 16);
|
||||||
|
const b = parseInt(full.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Relative luminance (WCAG) of a #rrggbb color, in the 0..1 range. */
|
||||||
|
export const luminance = (hex: string): number => {
|
||||||
|
const full = expandHex(hex);
|
||||||
|
const channel = (v: number): number => {
|
||||||
|
const s = v / 255;
|
||||||
|
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
const r = channel(parseInt(full.slice(1, 3), 16));
|
||||||
|
const g = channel(parseInt(full.slice(3, 5), 16));
|
||||||
|
const b = channel(parseInt(full.slice(5, 7), 16));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fill luminance at which our two ink colors (#0b0f17 dark, #f5f5f5
|
||||||
|
* light) give EQUAL contrast against it — below this, dark ink actually
|
||||||
|
* contrasts better even though the fill isn't "dark" in the everyday sense.
|
||||||
|
* Solving (fill+0.05)/(inkDark+0.05) = (inkLight+0.05)/(fill+0.05) for our
|
||||||
|
* actual ink luminances (~0.0047 and ~0.913) gives ≈0.1796.
|
||||||
|
*
|
||||||
|
* The previous threshold (0.55) was picked without this math and left a
|
||||||
|
* wide "medium" band — anything from ~0.18 to 0.55 luminance — defaulting to
|
||||||
|
* light ink even where dark ink was the better (sometimes WCAG-AA-passing)
|
||||||
|
* choice. The brand orange (#FF5A1F, luminance ≈0.29) was exactly such a
|
||||||
|
* case: white text on it is only 2.86:1 (fails the 4.5:1 AA minimum), while
|
||||||
|
* dark ink on it is ≈6.3:1.
|
||||||
|
*/
|
||||||
|
export const LIGHT_INK_LUMINANCE_THRESHOLD = 0.18;
|
||||||
|
|
||||||
|
export interface ResolvedColor {
|
||||||
|
/** The resolved #rrggbb fill. */
|
||||||
|
fill: string;
|
||||||
|
/** A contrasting ink (near-black on light fills, off-white on dark ones). */
|
||||||
|
ink: string;
|
||||||
|
/** True when the fill is light enough that dark ink reads better on it. */
|
||||||
|
isLight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a stored shirt-color value (a hex, or nothing) into a resolved fill
|
||||||
|
* plus the ink color that stays legible on top of it.
|
||||||
|
*/
|
||||||
|
export const resolveShirtColor = (value?: string | null): ResolvedColor => {
|
||||||
|
const raw = (value ?? '').trim().toLowerCase();
|
||||||
|
const fill = HEX_RE.test(raw) ? expandHex(raw) : brand.navyLight;
|
||||||
|
const isLight = luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD;
|
||||||
|
return { fill, ink: isLight ? '#0b0f17' : '#f5f5f5', isLight };
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_JERSEY_STYLE,
|
||||||
|
JERSEY_STYLES,
|
||||||
|
isJerseyStyle,
|
||||||
|
toJerseyStyle,
|
||||||
|
} from './jerseyStyles';
|
||||||
|
|
||||||
|
describe('jerseyStyles', () => {
|
||||||
|
it('exposes a labelled option per template', () => {
|
||||||
|
expect(JERSEY_STYLES.map(s => s.value)).toEqual([
|
||||||
|
'solid',
|
||||||
|
'stripes',
|
||||||
|
'hoops',
|
||||||
|
'diagonal',
|
||||||
|
'chevron',
|
||||||
|
'sash',
|
||||||
|
'sides',
|
||||||
|
'halves',
|
||||||
|
'circles',
|
||||||
|
'gradient',
|
||||||
|
'vneck',
|
||||||
|
'pinstripe',
|
||||||
|
'yoke',
|
||||||
|
'colorblock',
|
||||||
|
'arrow',
|
||||||
|
'camo',
|
||||||
|
'checkerboard',
|
||||||
|
'diamonds',
|
||||||
|
'star',
|
||||||
|
'triband',
|
||||||
|
'shoulder',
|
||||||
|
'splitTri',
|
||||||
|
'frame',
|
||||||
|
'crossband',
|
||||||
|
'ring',
|
||||||
|
]);
|
||||||
|
JERSEY_STYLES.forEach(s => expect(s.label.trim().length).toBeGreaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows only known styles', () => {
|
||||||
|
expect(isJerseyStyle('stripes')).toBe(true);
|
||||||
|
expect(isJerseyStyle('bogus')).toBe(false);
|
||||||
|
expect(isJerseyStyle(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces unknown values to the default style', () => {
|
||||||
|
expect(toJerseyStyle('sash')).toBe('sash');
|
||||||
|
expect(toJerseyStyle('bogus')).toBe(DEFAULT_JERSEY_STYLE);
|
||||||
|
expect(toJerseyStyle(null)).toBe(DEFAULT_JERSEY_STYLE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Jersey templates — the selectable "kit" patterns a team can pick, in the
|
||||||
|
* spirit of the Wikipedia team-kit diagrams (a fillable body plus a pattern
|
||||||
|
* layer). Each template is rendered by {@link JerseySvg} from the team's
|
||||||
|
* colors: a primary body color, a secondary color used for the pattern/trim,
|
||||||
|
* and — for the tri-color templates only — an optional third accent color.
|
||||||
|
* Authoring the shapes ourselves keeps them inline, tintable at runtime, and
|
||||||
|
* free of any external asset.
|
||||||
|
*/
|
||||||
|
export type JerseyStyle =
|
||||||
|
| 'solid'
|
||||||
|
| 'stripes'
|
||||||
|
| 'hoops'
|
||||||
|
| 'diagonal'
|
||||||
|
| 'chevron'
|
||||||
|
| 'sash'
|
||||||
|
| 'sides'
|
||||||
|
| 'halves'
|
||||||
|
| 'circles'
|
||||||
|
| 'gradient'
|
||||||
|
| 'vneck'
|
||||||
|
| 'pinstripe'
|
||||||
|
| 'yoke'
|
||||||
|
| 'colorblock'
|
||||||
|
| 'arrow'
|
||||||
|
| 'camo'
|
||||||
|
| 'checkerboard'
|
||||||
|
| 'diamonds'
|
||||||
|
| 'star'
|
||||||
|
| 'triband'
|
||||||
|
| 'shoulder'
|
||||||
|
| 'splitTri'
|
||||||
|
| 'frame'
|
||||||
|
| 'crossband'
|
||||||
|
| 'ring';
|
||||||
|
|
||||||
|
export interface JerseyStyleOption {
|
||||||
|
value: JerseyStyle;
|
||||||
|
/** Spanish label shown in the picker. */
|
||||||
|
label: string;
|
||||||
|
/** Whether this template uses a third accent color when one is set. */
|
||||||
|
usesTertiary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JERSEY_STYLES: JerseyStyleOption[] = [
|
||||||
|
{ value: 'solid', label: 'Lisa' },
|
||||||
|
{ value: 'stripes', label: 'Rayas verticales' },
|
||||||
|
{ value: 'hoops', label: 'Franjas horizontales' },
|
||||||
|
{ value: 'diagonal', label: 'Rayas diagonales' },
|
||||||
|
{ value: 'chevron', label: 'Chevrón' },
|
||||||
|
{ value: 'sash', label: 'Banda diagonal' },
|
||||||
|
{ value: 'sides', label: 'Laterales' },
|
||||||
|
{ value: 'halves', label: 'Mitades' },
|
||||||
|
{ value: 'circles', label: 'Lunares' },
|
||||||
|
{ value: 'gradient', label: 'Degradé' },
|
||||||
|
{ value: 'vneck', label: 'Cuello en V' },
|
||||||
|
{ value: 'pinstripe', label: 'Rayas finas' },
|
||||||
|
{ value: 'yoke', label: 'Canesú' },
|
||||||
|
{ value: 'colorblock', label: 'Bloque diagonal' },
|
||||||
|
{ value: 'arrow', label: 'Flecha' },
|
||||||
|
{ value: 'camo', label: 'Camuflaje' },
|
||||||
|
{ value: 'checkerboard', label: 'Cuadros' },
|
||||||
|
{ value: 'diamonds', label: 'Rombos' },
|
||||||
|
{ value: 'star', label: 'Estrella' },
|
||||||
|
{ value: 'triband', label: 'Tribanda', usesTertiary: true },
|
||||||
|
{ value: 'shoulder', label: 'Hombreras' },
|
||||||
|
{ value: 'splitTri', label: 'Tres paneles', usesTertiary: true },
|
||||||
|
{ value: 'frame', label: 'Marco', usesTertiary: true },
|
||||||
|
{ value: 'crossband', label: 'Cruz diagonal' },
|
||||||
|
{ value: 'ring', label: 'Aro numeral', usesTertiary: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_JERSEY_STYLE: JerseyStyle = 'solid';
|
||||||
|
|
||||||
|
/** Narrows an arbitrary stored string to a known {@link JerseyStyle}. */
|
||||||
|
export const isJerseyStyle = (value: unknown): value is JerseyStyle =>
|
||||||
|
typeof value === 'string' && JERSEY_STYLES.some(style => style.value === value);
|
||||||
|
|
||||||
|
/** Coerces a stored value into a valid style, defaulting when unrecognized. */
|
||||||
|
export const toJerseyStyle = (value: unknown): JerseyStyle =>
|
||||||
|
isJerseyStyle(value) ? value : DEFAULT_JERSEY_STYLE;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Club 12 design tokens — the single source of truth for the brand's visual
|
||||||
|
* language (dark-first, orange accent, navy "scoreboard" chrome). These values
|
||||||
|
* were previously scattered as private constants inside `theme.ts`; centralizing
|
||||||
|
* them here lets components and one-off surfaces read the same hues, radii and
|
||||||
|
* spacing the MUI theme is built from, instead of hardcoding hex strings.
|
||||||
|
*
|
||||||
|
* The MUI theme (`theme.ts`) is assembled from these tokens; prefer reading
|
||||||
|
* `theme.palette` inside components. Reach for a raw token only when you need a
|
||||||
|
* value the theme does not expose (e.g. a specific surface layer for a custom
|
||||||
|
* gradient or the jersey/hero accents).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Brand hues. Orange is the single accent; navy is the secondary chrome hue. */
|
||||||
|
export const brand = {
|
||||||
|
orange: '#FF5A1F',
|
||||||
|
orangeLight: '#FF8A50',
|
||||||
|
orangeDark: '#C43E00',
|
||||||
|
/** Near-black ink for labels on filled orange (AA-safe, ~5.6:1). */
|
||||||
|
orangeInk: '#0B0F17',
|
||||||
|
navy: '#0F172A',
|
||||||
|
navyLight: '#1E293B',
|
||||||
|
/** The club's championship gold — used for champions, podium and finals. */
|
||||||
|
gold: '#E6A817',
|
||||||
|
goldLight: '#F5C542',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playoff qualification tier colors (HU-45), used to highlight the standings
|
||||||
|
* rows that qualify to each cup. Gold-silver-bronze for the top three cups,
|
||||||
|
* then the brand orange for any further cup below the podium three. Silver and
|
||||||
|
* bronze are muted metallics tuned to stay legible on the dark canvas.
|
||||||
|
*/
|
||||||
|
export const cupTier = {
|
||||||
|
gold: brand.gold,
|
||||||
|
silver: '#C7CDD6',
|
||||||
|
bronze: '#CD8E5A',
|
||||||
|
accent: brand.orange,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category accent colors taken from the club's own branding: masculine pieces
|
||||||
|
* are the warm orange, feminine pieces a vivid purple/magenta. Used to tint
|
||||||
|
* category chips and the masculine/feminine sections so a visitor tells them
|
||||||
|
* apart at a glance.
|
||||||
|
*/
|
||||||
|
export const category = {
|
||||||
|
masculine: brand.orange,
|
||||||
|
feminine: '#A32CC4',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layered dark surfaces (canvas -> paper -> raised). A deliberate three-step
|
||||||
|
* scale so depth reads through elevation, never through a colored overlay.
|
||||||
|
*/
|
||||||
|
export const surface = {
|
||||||
|
canvas: '#111827', // L0 app canvas
|
||||||
|
paper: '#1A2232', // L1 cards, drawers, app surfaces
|
||||||
|
raised: '#232D3F', // L2 inputs, menus, hovered rows
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Light-mode surfaces, retained for the legacy light branch of the theme. */
|
||||||
|
export const surfaceLight = {
|
||||||
|
canvas: '#F4F6F9',
|
||||||
|
paper: '#FFFFFF',
|
||||||
|
raised: '#FFFFFF',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ink = {
|
||||||
|
primary: '#E7EAF0',
|
||||||
|
secondary: '#98A2B3',
|
||||||
|
primaryLight: brand.navy,
|
||||||
|
secondaryLight: '#516072',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Semantic hues tuned to stay legible on the dark canvas. */
|
||||||
|
export const semantic = {
|
||||||
|
success: '#00C853',
|
||||||
|
warning: '#F5A524',
|
||||||
|
info: '#38BDF8',
|
||||||
|
error: '#d32f2f',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const dividerColor = {
|
||||||
|
dark: 'rgba(231, 234, 240, 0.12)',
|
||||||
|
light: 'rgba(15, 23, 42, 0.12)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Club 12 logo asset bakes its dark maroon backdrop into the PNG (no alpha),
|
||||||
|
* so surfaces wrapping the logo use this matching color to read as a badge.
|
||||||
|
*/
|
||||||
|
export const logoBackground = '#4D0000';
|
||||||
|
|
||||||
|
/** The SweetAlert cancel affordance color, reused for destructive controls. */
|
||||||
|
export const cancelColor = '#d33';
|
||||||
|
|
||||||
|
/** Corner radii, in px, as a small deliberate scale. */
|
||||||
|
export const radius = {
|
||||||
|
sm: 6,
|
||||||
|
md: 8,
|
||||||
|
lg: 10,
|
||||||
|
xl: 16,
|
||||||
|
pill: 999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Base spacing unit (px). The MUI `spacing()` factor stays at the default 8. */
|
||||||
|
export const spacingUnit = 8;
|
||||||
|
|
||||||
|
/** Typeface roles. Oswald (condensed, uppercase) carries the sporting display
|
||||||
|
* voice; Roboto handles body copy and data. */
|
||||||
|
export const font = {
|
||||||
|
display: "'Oswald', sans-serif",
|
||||||
|
body: "'Roboto', sans-serif",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant reserved height for a page's main content area, so a view is the
|
||||||
|
* same height while its data loads (skeleton) and once it arrives — no layout
|
||||||
|
* jump. Sized to fill the viewport below the public header and above the
|
||||||
|
* footer without forcing a scroll on an empty page.
|
||||||
|
*/
|
||||||
|
export const pageMinHeight = 'calc(100vh - 220px)';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* `@g-loot/react-tournament-brackets@1.0.31-rc`'s `package.json` declares
|
||||||
|
* `"types": "dist/index.d.ts"`, but that file doesn't exist in the
|
||||||
|
* published package (only `dist/esm/index.d.ts` and `dist/cjs/index.d.ts`
|
||||||
|
* do) — a packaging bug in this pre-release build. This ambient
|
||||||
|
* declaration re-points the bare specifier at the real declaration file so
|
||||||
|
* the rest of the app can `import ... from '@g-loot/react-tournament-brackets'`
|
||||||
|
* normally. Safe to delete once a release fixes the `types` field upstream.
|
||||||
|
*/
|
||||||
|
declare module '@g-loot/react-tournament-brackets' {
|
||||||
|
export * from '@g-loot/react-tournament-brackets/dist/esm/index';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ComponentType, ReactNode } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
// Side-effect import: registers the global maintenance banner against
|
||||||
|
// axiosUtils' onStatusCode(HttpStatus.ServiceUnavailable, ...) registry.
|
||||||
|
import './modules/core/utils/maintenanceBanner';
|
||||||
|
import { AuthProvider } from './modules/auth/context/auth.context';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ErrorProvider } from './modules/error/context/error.context';
|
||||||
|
import { TournamentProvider } from './modules/tournament/context/tournament.context';
|
||||||
|
import { VenueProvider } from './modules/venue/context/venue.context';
|
||||||
|
import { SeasonProvider } from './modules/season/context/season.context';
|
||||||
|
import { TeamProvider } from './modules/team/context/team.context';
|
||||||
|
import { ClubProvider } from './modules/club/context/club.context';
|
||||||
|
import { UserProvider } from './modules/user/context/user.context';
|
||||||
|
import { DivisionProvider } from './modules/division/context/division.context';
|
||||||
|
import { PlayerProvider } from './modules/player/context/player.context';
|
||||||
|
import { StageProvider } from './modules/stage/context/stage.context';
|
||||||
|
import { MatchProvider } from './modules/match/context/match.context';
|
||||||
|
import { PlayerSanctionProvider } from './modules/playerSanction/context/playerSanction.context';
|
||||||
|
import { PlayerStatisticProvider } from './modules/playerStatistic/context/playerStatistic.context';
|
||||||
|
import { ScorerProvider } from './modules/scorer/context/scorer.context';
|
||||||
|
import { BlogPostProvider } from './modules/blogPost/context/blogPost.context';
|
||||||
|
import { MedicalRecordProvider } from './modules/medicalRecord/context/medicalRecord.context';
|
||||||
|
import { AuditLogProvider } from './modules/auditLog/context/auditLog.context';
|
||||||
|
import ErrorBoundary from './views/core/errors/error-boundary';
|
||||||
|
import ComposeProviders from './views/core/components/ComposeProviders';
|
||||||
|
import QueryProvider from './views/core/components/QueryProvider';
|
||||||
|
import ThemedProvider from './views/core/components/ThemedProvider';
|
||||||
|
|
||||||
|
const providers: ComponentType<{ children: ReactNode }>[] = [
|
||||||
|
ErrorBoundary,
|
||||||
|
QueryProvider,
|
||||||
|
ThemedProvider,
|
||||||
|
BrowserRouter,
|
||||||
|
ErrorProvider,
|
||||||
|
AuthProvider,
|
||||||
|
VenueProvider,
|
||||||
|
SeasonProvider,
|
||||||
|
TeamProvider,
|
||||||
|
ClubProvider,
|
||||||
|
PlayerProvider,
|
||||||
|
UserProvider,
|
||||||
|
TournamentProvider,
|
||||||
|
DivisionProvider,
|
||||||
|
StageProvider,
|
||||||
|
MatchProvider,
|
||||||
|
PlayerSanctionProvider,
|
||||||
|
ScorerProvider,
|
||||||
|
PlayerStatisticProvider,
|
||||||
|
BlogPostProvider,
|
||||||
|
MedicalRecordProvider,
|
||||||
|
AuditLogProvider,
|
||||||
|
];
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ComposeProviders providers={providers}>
|
||||||
|
<App />
|
||||||
|
</ComposeProviders>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { createContext, ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { GenericResponsePagination } from '@/modules/core/types/types';
|
||||||
|
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
|
||||||
|
import { auditLogService } from '@/modules/auditLog/service/auditLog.service';
|
||||||
|
import {
|
||||||
|
AuditLogFiltered,
|
||||||
|
IAuditLogContextProps,
|
||||||
|
IAuditLogResponse,
|
||||||
|
} from '@/modules/auditLog/type/auditLog';
|
||||||
|
import { auditLogKeys } from '@/modules/auditLog/queryKeys';
|
||||||
|
|
||||||
|
export const AuditLogContext = createContext<IAuditLogContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AuditLogProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const handleUnknownError = useUnknownErrorHandler();
|
||||||
|
|
||||||
|
const getAuditLogs = useCallback(
|
||||||
|
async (
|
||||||
|
filter: AuditLogFiltered
|
||||||
|
): Promise<GenericResponsePagination<IAuditLogResponse> | void> => {
|
||||||
|
try {
|
||||||
|
const response = await queryClient.fetchQuery({
|
||||||
|
queryKey: auditLogKeys.list(filter),
|
||||||
|
queryFn: async () => await auditLogService.getAuditLogs(filter),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const container: IAuditLogContextProps = useMemo(
|
||||||
|
() => ({ getAuditLogs }),
|
||||||
|
[getAuditLogs]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuditLogContext.Provider value={container}>
|
||||||
|
{children}
|
||||||
|
</AuditLogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuditLogContext } from '@/modules/auditLog/context/auditLog.context';
|
||||||
|
|
||||||
|
export const useAuditLog = () => {
|
||||||
|
const context = useContext(AuditLogContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuditLog must be used within an AuditLogProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { AuditLogFiltered } from '@/modules/auditLog/type/auditLog';
|
||||||
|
|
||||||
|
export const auditLogKeys = {
|
||||||
|
list: (filter?: AuditLogFiltered) =>
|
||||||
|
filter === undefined
|
||||||
|
? (['auditLog', 'list'] as const)
|
||||||
|
: (['auditLog', 'list', filter] as const),
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { withTablePageSize } from '@/modules/core/constants/pagination';
|
||||||
|
import { GenericResponsePagination } from '@/modules/core/types/types';
|
||||||
|
import { sendGet } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
AuditLogFiltered,
|
||||||
|
IAuditLogResponse,
|
||||||
|
} from '@/modules/auditLog/type/auditLog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only service for the sensitive-action audit trail (HU-101).
|
||||||
|
*/
|
||||||
|
export const auditLogService = {
|
||||||
|
/**
|
||||||
|
* Fetches audit entries (newest first) with pagination and optional filters.
|
||||||
|
* @param {AuditLogFiltered} filter - The filter criteria to apply.
|
||||||
|
* @returns {Promise<AxiosResponse<GenericResponsePagination<IAuditLogResponse>>>}
|
||||||
|
*/
|
||||||
|
getAuditLogs: async (
|
||||||
|
filter: AuditLogFiltered
|
||||||
|
): Promise<AxiosResponse<GenericResponsePagination<IAuditLogResponse>>> =>
|
||||||
|
sendGet<GenericResponsePagination<IAuditLogResponse>>(
|
||||||
|
routes.auditLogs,
|
||||||
|
withTablePageSize(filter)
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Filtered,
|
||||||
|
GenericResponsePagination,
|
||||||
|
GUID,
|
||||||
|
} from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sensitive, auditable actions tracked by the backend (HU-101). Persisted
|
||||||
|
* as the enum name, so the frontend receives these exact strings.
|
||||||
|
*/
|
||||||
|
export type AuditAction =
|
||||||
|
| 'DataWipe'
|
||||||
|
| 'BackupRestore'
|
||||||
|
| 'TournamentStatusChange'
|
||||||
|
| 'PasswordReset'
|
||||||
|
| 'PlayoffDraw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single audit-trail entry as returned by `GET /api/audit-logs` (HU-101).
|
||||||
|
* @interface IAuditLogResponse
|
||||||
|
*/
|
||||||
|
export interface IAuditLogResponse {
|
||||||
|
/** The unique identifier of the audit entry. */
|
||||||
|
id: GUID;
|
||||||
|
|
||||||
|
/** The sensitive action that was performed (enum name). */
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
/** Who performed the action (email, or "System"). */
|
||||||
|
actor: string;
|
||||||
|
|
||||||
|
/** The kind of entity targeted, when applicable. */
|
||||||
|
targetType?: string | null;
|
||||||
|
|
||||||
|
/** Identifier of the targeted entity, when applicable. */
|
||||||
|
targetId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target's human-readable name/label at the moment the action was
|
||||||
|
* performed. Null for actions with no single named target, or entries
|
||||||
|
* written before this field existed (fall back to targetId for those).
|
||||||
|
*/
|
||||||
|
targetName?: string | null;
|
||||||
|
|
||||||
|
/** Free-form human-readable context. */
|
||||||
|
detail?: string | null;
|
||||||
|
|
||||||
|
/** When the action happened (UTC). */
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtering and pagination for the audit-trail listing (HU-101).
|
||||||
|
* @interface AuditLogFiltered
|
||||||
|
*/
|
||||||
|
export interface AuditLogFiltered extends Filtered {
|
||||||
|
/** Optional filter by the actor (who performed the action). */
|
||||||
|
actor?: string;
|
||||||
|
|
||||||
|
/** Optional filter by the action type. */
|
||||||
|
action?: AuditAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context surface for reading the audit trail (HU-101).
|
||||||
|
* @interface IAuditLogContextProps
|
||||||
|
*/
|
||||||
|
export interface IAuditLogContextProps {
|
||||||
|
/**
|
||||||
|
* Fetches audit entries (newest first) with pagination and optional filters.
|
||||||
|
* @param filter The filter criteria to apply.
|
||||||
|
*/
|
||||||
|
getAuditLogs(
|
||||||
|
filter: AuditLogFiltered
|
||||||
|
): Promise<GenericResponsePagination<IAuditLogResponse> | void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { ErrorProvider } from '@/modules/error/context/error.context';
|
||||||
|
import { AuthProvider } from '@/modules/auth/context/auth.context';
|
||||||
|
import { useAuth } from '@/modules/auth/hook/auth.hook';
|
||||||
|
import { authService } from '@/modules/auth/service/auth.service';
|
||||||
|
import { ERROR_MESSAGES } from '@/modules/core/constants/constants';
|
||||||
|
|
||||||
|
vi.mock('@/modules/auth/service/auth.service');
|
||||||
|
vi.mock('sweetalert2', () => ({
|
||||||
|
default: {
|
||||||
|
fire: vi.fn(),
|
||||||
|
getContainer: vi.fn().mockReturnValue(null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedLoginRequest = vi.mocked(authService.loginRequest);
|
||||||
|
const mockedSwalFire = vi.mocked(Swal.fire);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ErrorProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</ErrorProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthProvider — signIn failure', () => {
|
||||||
|
it('shows exactly one Spanish toast and resolves false, never the raw backend error', async () => {
|
||||||
|
mockedLoginRequest.mockRejectedValueOnce(
|
||||||
|
new Error('Invalid credentials.')
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
|
let success: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
success = await result.current.signIn({
|
||||||
|
email: 'wrong@club12.test',
|
||||||
|
password: 'wrong',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(mockedSwalFire).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSwalFire).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
icon: 'error',
|
||||||
|
title: ERROR_MESSAGES.LOGIN_FAILED,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { decodeToken } from 'react-jwt';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ProviderProps } from '@/modules/core/types/types';
|
||||||
|
import { useError } from '@/modules/error/hooks/error.hock';
|
||||||
|
import { authService } from '@/modules/auth/service/auth.service';
|
||||||
|
import {
|
||||||
|
AuthResponse,
|
||||||
|
IAuthContextProps,
|
||||||
|
IUser,
|
||||||
|
LogInUserRequest,
|
||||||
|
} from '@/modules/auth/type/auth';
|
||||||
|
|
||||||
|
import {
|
||||||
|
COOKIE_SIGNIN_TOKEN,
|
||||||
|
ERROR_MESSAGES,
|
||||||
|
SUCCESS_MESSAGES,
|
||||||
|
EXPIRATION_TIME,
|
||||||
|
JWT,
|
||||||
|
} from '@/modules/core/constants/constants';
|
||||||
|
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
|
||||||
|
import { authKeys } from '@/modules/auth/queryKeys';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
|
||||||
|
export const AuthContext = createContext<IAuthContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const ROLE_CLAIM =
|
||||||
|
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
|
||||||
|
const ROLE_CLAIM_LEGACY =
|
||||||
|
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role';
|
||||||
|
|
||||||
|
const ROLE_NORMALIZATION_MAP: Record<string, UserRolesType> = {
|
||||||
|
admin: UserRolesType.Admin,
|
||||||
|
administrador: UserRolesType.Admin,
|
||||||
|
owner: UserRolesType.Owner,
|
||||||
|
duenio: UserRolesType.Owner,
|
||||||
|
dueño: UserRolesType.Owner,
|
||||||
|
guest: UserRolesType.Guest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseExpiresInToMs = (expiresIn: string): number => {
|
||||||
|
const [dayPart, timePart] = expiresIn.includes('.')
|
||||||
|
? expiresIn.split('.')
|
||||||
|
: [undefined, expiresIn];
|
||||||
|
const [hours = 0, minutes = 0, seconds = 0] = timePart.split(':').map(Number);
|
||||||
|
const days = dayPart ? Number(dayPart) : 0;
|
||||||
|
|
||||||
|
if ([days, hours, minutes, seconds].some(Number.isNaN)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
days * 24 * EXPIRATION_TIME.MS_IN_HOUR +
|
||||||
|
hours * EXPIRATION_TIME.MS_IN_HOUR +
|
||||||
|
minutes * EXPIRATION_TIME.MS_IN_MINUTE +
|
||||||
|
seconds * EXPIRATION_TIME.MS_IN_SECOND
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserRoleFromToken = (accessToken: string): UserRolesType => {
|
||||||
|
const payload = decodeToken<Record<string, unknown>>(accessToken);
|
||||||
|
if (!payload) {
|
||||||
|
return UserRolesType.Guest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitRoleClaims = [
|
||||||
|
payload[ROLE_CLAIM],
|
||||||
|
payload[ROLE_CLAIM_LEGACY],
|
||||||
|
payload.role,
|
||||||
|
payload.roles,
|
||||||
|
payload.Role,
|
||||||
|
payload.Roles,
|
||||||
|
];
|
||||||
|
|
||||||
|
const dynamicRoleClaims = Object.entries(payload)
|
||||||
|
.filter(([key]) => key.toLowerCase().includes('role'))
|
||||||
|
.map(([, value]) => value);
|
||||||
|
|
||||||
|
const rawRoleValues = [...explicitRoleClaims, ...dynamicRoleClaims].flatMap(
|
||||||
|
value => (Array.isArray(value) ? value : [value])
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedRole = rawRoleValues
|
||||||
|
.filter((value): value is string => typeof value === 'string')
|
||||||
|
.flatMap(value => value.split(','))
|
||||||
|
.map(value => value.trim().toLowerCase())
|
||||||
|
.find(value => Boolean(ROLE_NORMALIZATION_MAP[value]));
|
||||||
|
|
||||||
|
return normalizedRole
|
||||||
|
? ROLE_NORMALIZATION_MAP[normalizedRole]
|
||||||
|
: UserRolesType.Guest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<ProviderProps> = ({ children }) => {
|
||||||
|
// Both `user` (which drives `role`) and `isAuthenticated` are seeded
|
||||||
|
// synchronously from the cookie so route guards never see a false
|
||||||
|
// "not authenticated" / wrong-role flash on first render or hard reload
|
||||||
|
// while the async `hasToken` query below is still settling. That flash
|
||||||
|
// was pushing an extra /login or /forbidden history entry via
|
||||||
|
// PrivateRoute, breaking the browser back button.
|
||||||
|
const [user, setUser] = useState<IUser | null>(() => {
|
||||||
|
const token = Cookies.get(COOKIE_SIGNIN_TOKEN);
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
accessToken: { accessToken: token, expiresIn: '00:00:00', refreshToken: null },
|
||||||
|
role: getUserRoleFromToken(token),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() =>
|
||||||
|
Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN))
|
||||||
|
);
|
||||||
|
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const { setError, setMessage } = useError();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: hasToken } = useQuery({
|
||||||
|
queryKey: authKeys.hasToken(),
|
||||||
|
queryFn: async () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)),
|
||||||
|
staleTime: Infinity,
|
||||||
|
initialData: () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signInMutation = useMutation({
|
||||||
|
mutationFn: authService.loginRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logOutMutation = useMutation({
|
||||||
|
mutationFn: authService.logoutRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTokenMutation = useMutation({
|
||||||
|
mutationFn: authService.refreshTokenRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAuthTimeout = useCallback(() => {
|
||||||
|
if (authTimeoutRef.current) {
|
||||||
|
clearTimeout(authTimeoutRef.current);
|
||||||
|
authTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAuthStorage = useCallback(() => {
|
||||||
|
Cookies.remove(COOKIE_SIGNIN_TOKEN);
|
||||||
|
localStorage.removeItem(JWT.REFRESH_TOKEN);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
queryClient.setQueryData(authKeys.hasToken(), false);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const applyAuthData = useCallback(
|
||||||
|
(authData: AuthResponse, username?: string) => {
|
||||||
|
const userRole = getUserRoleFromToken(authData.accessToken);
|
||||||
|
const expiresInMs = parseExpiresInToMs(authData.expiresIn);
|
||||||
|
|
||||||
|
setUser(prevUser => ({
|
||||||
|
username: username ?? prevUser?.username ?? '',
|
||||||
|
accessToken: authData,
|
||||||
|
role: userRole,
|
||||||
|
}));
|
||||||
|
|
||||||
|
Cookies.set(
|
||||||
|
COOKIE_SIGNIN_TOKEN,
|
||||||
|
authData.accessToken,
|
||||||
|
expiresInMs > 0
|
||||||
|
? { expires: expiresInMs / (1000 * 60 * 60 * 24) }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authData.refreshToken) {
|
||||||
|
localStorage.setItem(JWT.REFRESH_TOKEN, authData.refreshToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(JWT.REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
queryClient.setQueryData(authKeys.hasToken(), true);
|
||||||
|
|
||||||
|
return expiresInMs;
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshAuthToken = useCallback(async (): Promise<boolean> => {
|
||||||
|
const refreshToken = localStorage.getItem(JWT.REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) {
|
||||||
|
clearAuthTimeout();
|
||||||
|
clearAuthStorage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await refreshTokenMutation.mutateAsync({ refreshToken });
|
||||||
|
if (res?.status === HttpStatus.Ok && res?.data) {
|
||||||
|
const expiresInMs = applyAuthData(res.data as AuthResponse);
|
||||||
|
|
||||||
|
clearAuthTimeout();
|
||||||
|
if (expiresInMs > 0) {
|
||||||
|
authTimeoutRef.current = setTimeout(() => {
|
||||||
|
void refreshAuthToken();
|
||||||
|
}, expiresInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(error as AxiosError);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthTimeout();
|
||||||
|
clearAuthStorage();
|
||||||
|
setUser(null);
|
||||||
|
return false;
|
||||||
|
}, [
|
||||||
|
refreshTokenMutation,
|
||||||
|
applyAuthData,
|
||||||
|
clearAuthStorage,
|
||||||
|
clearAuthTimeout,
|
||||||
|
setError,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAuthenticated(Boolean(hasToken));
|
||||||
|
}, [hasToken]);
|
||||||
|
|
||||||
|
const signIn = useCallback(
|
||||||
|
async (userData: LogInUserRequest): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await signInMutation.mutateAsync(userData);
|
||||||
|
if (res?.status === HttpStatus.Ok && res?.data) {
|
||||||
|
const authData = res.data as AuthResponse;
|
||||||
|
const expiresInMs = applyAuthData(authData, userData.email);
|
||||||
|
|
||||||
|
clearAuthTimeout();
|
||||||
|
if (expiresInMs > 0) {
|
||||||
|
authTimeoutRef.current = setTimeout(() => {
|
||||||
|
void refreshAuthToken();
|
||||||
|
}, expiresInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(res.status, [SUCCESS_MESSAGES.LOGIN_SUCCESS]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fire the one standard Spanish toast (same as every other flow) and
|
||||||
|
// return false so the caller doesn't also show its own message. We
|
||||||
|
// deliberately do NOT surface the raw API error text here — that's
|
||||||
|
// the backend's English "Invalid credentials.", not this message.
|
||||||
|
setMessage(HttpStatus.Unauthorized, [ERROR_MESSAGES.LOGIN_FAILED]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setMessage,
|
||||||
|
signInMutation,
|
||||||
|
applyAuthData,
|
||||||
|
clearAuthTimeout,
|
||||||
|
refreshAuthToken,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await logOutMutation.mutateAsync();
|
||||||
|
clearAuthTimeout();
|
||||||
|
clearAuthStorage();
|
||||||
|
setUser(null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(error as AxiosError);
|
||||||
|
}
|
||||||
|
}, [clearAuthStorage, clearAuthTimeout, setUser, setError, logOutMutation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearAuthTimeout();
|
||||||
|
};
|
||||||
|
}, [clearAuthTimeout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Cookies.get(COOKIE_SIGNIN_TOKEN);
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(prevUser => {
|
||||||
|
if (prevUser?.accessToken?.accessToken === token) {
|
||||||
|
return prevUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = getUserRoleFromToken(token);
|
||||||
|
return {
|
||||||
|
username: prevUser?.username ?? '',
|
||||||
|
accessToken: {
|
||||||
|
accessToken: token,
|
||||||
|
expiresIn: prevUser?.accessToken?.expiresIn ?? '00:00:00',
|
||||||
|
refreshToken: prevUser?.accessToken?.refreshToken ?? null,
|
||||||
|
},
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [hasToken]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
signIn,
|
||||||
|
logOut,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
role: user?.role ?? UserRolesType.Guest,
|
||||||
|
}),
|
||||||
|
[signIn, logOut, user, isAuthenticated]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from '@/modules/auth/context/auth.context';
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used whithin an Auth Provider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { authKeys } from './queryKeys';
|
||||||
|
|
||||||
|
describe('authKeys', () => {
|
||||||
|
it('hasToken() returns the singleton literal', () => {
|
||||||
|
expect(authKeys.hasToken()).toEqual(['auth', 'has-token']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const authKeys = {
|
||||||
|
hasToken: () => ['auth', 'has-token'] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { authService } from '@/modules/auth/service/auth.service';
|
||||||
|
import { sendPost } from '@/modules/core/utils/axiosUtils';
|
||||||
|
|
||||||
|
vi.mock('@/modules/core/utils/axiosUtils', () => ({
|
||||||
|
sendPost: vi.fn(() => Promise.resolve({ status: 200, data: {} })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sendPostMock = vi.mocked(sendPost);
|
||||||
|
|
||||||
|
describe('authService magic-link endpoints (HU-09/HU-10)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
sendPostMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inviteRequest posts email + role to auth/invite', async () => {
|
||||||
|
await authService.inviteRequest({
|
||||||
|
email: 'nuevo@club12.com',
|
||||||
|
role: 'ADMIN',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendPostMock).toHaveBeenCalledWith('auth/invite', {
|
||||||
|
email: 'nuevo@club12.com',
|
||||||
|
role: 'ADMIN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activateRequest posts email + token + newPassword to auth/activate', async () => {
|
||||||
|
await authService.activateRequest({
|
||||||
|
email: 'nuevo@club12.com',
|
||||||
|
token: 'activation-token',
|
||||||
|
newPassword: 'Str0ng!Pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendPostMock).toHaveBeenCalledWith('auth/activate', {
|
||||||
|
email: 'nuevo@club12.com',
|
||||||
|
token: 'activation-token',
|
||||||
|
newPassword: 'Str0ng!Pass',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestPasswordResetRequest posts email to auth/password-reset/request', async () => {
|
||||||
|
await authService.requestPasswordResetRequest({
|
||||||
|
email: 'olvide@club12.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendPostMock).toHaveBeenCalledWith('auth/password-reset/request', {
|
||||||
|
email: 'olvide@club12.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { sendPost } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
ActivateAccountRequest,
|
||||||
|
AuthResponse,
|
||||||
|
InviteUserRequest,
|
||||||
|
InviteUserResponse,
|
||||||
|
LogInUserRequest,
|
||||||
|
PasswordResetConfirmRequest,
|
||||||
|
RefreshTokenRequest,
|
||||||
|
RequestPasswordResetRequest,
|
||||||
|
} from '@/modules/auth/type/auth';
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
loginRequest: (
|
||||||
|
user: LogInUserRequest
|
||||||
|
): Promise<AxiosResponse<AuthResponse> | undefined> =>
|
||||||
|
sendPost<AuthResponse>(`${routes.auth}/login`, user),
|
||||||
|
|
||||||
|
refreshTokenRequest: (
|
||||||
|
refreshToken: RefreshTokenRequest
|
||||||
|
): Promise<AxiosResponse<AuthResponse> | undefined> =>
|
||||||
|
sendPost<AuthResponse>(`${routes.auth}/refresh-token`, refreshToken),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-09: invites a user by email (Admin/Owner). The backend creates a
|
||||||
|
* passwordless account and emails a magic activation link.
|
||||||
|
*/
|
||||||
|
inviteRequest: (
|
||||||
|
payload: InviteUserRequest
|
||||||
|
): Promise<AxiosResponse<InviteUserResponse> | undefined> =>
|
||||||
|
sendPost<InviteUserResponse>(`${routes.auth}/invite`, payload),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-09: consumes the activation token from the invitation email and sets
|
||||||
|
* the invited user's first password.
|
||||||
|
*/
|
||||||
|
activateRequest: (
|
||||||
|
payload: ActivateAccountRequest
|
||||||
|
): Promise<AxiosResponse<AuthResponse> | undefined> =>
|
||||||
|
sendPost<AuthResponse>(`${routes.auth}/activate`, payload),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-10: self-service. Requests a password-reset magic link for the given
|
||||||
|
* email. Always resolves 200 (no account enumeration).
|
||||||
|
*/
|
||||||
|
requestPasswordResetRequest: (
|
||||||
|
payload: RequestPasswordResetRequest
|
||||||
|
): Promise<AxiosResponse<void> | undefined> =>
|
||||||
|
sendPost<void>(`${routes.auth}/password-reset/request`, payload),
|
||||||
|
|
||||||
|
confirmPasswordResetRequest: (
|
||||||
|
payload: PasswordResetConfirmRequest
|
||||||
|
): Promise<AxiosResponse<AuthResponse> | undefined> =>
|
||||||
|
sendPost<AuthResponse>(`${routes.auth}/password-reset/confirm`, payload),
|
||||||
|
|
||||||
|
logoutRequest: () => sendPost(`${routes.auth}/logout`),
|
||||||
|
};
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
import { UserRolesType } from '@/modules/core/enum/user/userRolesType';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the response object for authentication tokens.
|
||||||
|
* @interface TokenResponse
|
||||||
|
*/
|
||||||
|
export interface TokenResponse {
|
||||||
|
/**
|
||||||
|
* The access token used for authentication.
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expiration duration of the access token in date-span format.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
expiresIn: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The refresh token used for refreshing the access token.
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
|
refreshToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the request object to refresh the access token using the refresh token.
|
||||||
|
* @interface RefreshTokenRequest
|
||||||
|
*/
|
||||||
|
export interface RefreshTokenRequest {
|
||||||
|
/**
|
||||||
|
* The refresh token used to get a new access token.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a password reset confirmation request from an email link.
|
||||||
|
* @interface PasswordResetConfirmRequest
|
||||||
|
*/
|
||||||
|
export interface PasswordResetConfirmRequest {
|
||||||
|
/**
|
||||||
|
* Email associated with the user account.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token received by email for password reset.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New password to set.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-09: request to invite a user by email only (no password). The backend
|
||||||
|
* creates a passwordless account and emails a magic activation link. Requires
|
||||||
|
* Admin or Owner.
|
||||||
|
* @interface InviteUserRequest
|
||||||
|
*/
|
||||||
|
export interface InviteUserRequest {
|
||||||
|
/**
|
||||||
|
* Email the invitation/activation link is sent to.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional contact phone number.
|
||||||
|
* @type {string | undefined}
|
||||||
|
*/
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target role. Accepted values: ADMIN, OWNER.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-09: confirmation payload returned after inviting a user by email.
|
||||||
|
* @interface InviteUserResponse
|
||||||
|
*/
|
||||||
|
export interface InviteUserResponse {
|
||||||
|
/**
|
||||||
|
* Id of the newly created (passwordless) user.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email the activation link was sent to.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigned role.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-09: payload the invited user submits from the activation email link to
|
||||||
|
* set their first password and enable login.
|
||||||
|
* @interface ActivateAccountRequest
|
||||||
|
*/
|
||||||
|
export interface ActivateAccountRequest {
|
||||||
|
/**
|
||||||
|
* Email associated with the invited account.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activation token received by email.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First password the user chooses.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-10: self-service request to receive a password-reset magic link by email.
|
||||||
|
* @interface RequestPasswordResetRequest
|
||||||
|
*/
|
||||||
|
export interface RequestPasswordResetRequest {
|
||||||
|
/**
|
||||||
|
* Email to send the password-reset link to.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user login request.
|
||||||
|
* @interface LogInUserRequest
|
||||||
|
*/
|
||||||
|
export interface LogInUserRequest {
|
||||||
|
/**
|
||||||
|
* The username of the user.
|
||||||
|
* @type {string}
|
||||||
|
* @minLength 1
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The password of the user.
|
||||||
|
* @type {string}
|
||||||
|
* @minLength 1
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the response containing user authentication information.
|
||||||
|
* @interface AuthResponse
|
||||||
|
*/
|
||||||
|
export type AuthResponse = TokenResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the authentication context properties for sign-in, sign-out, and user information.
|
||||||
|
* @interface IAuthContextProps
|
||||||
|
*/
|
||||||
|
export interface IAuthContextProps {
|
||||||
|
/**
|
||||||
|
* Sign-in method that attempts to authenticate the user.
|
||||||
|
* @param value The login credentials.
|
||||||
|
* @returns {Promise<boolean>} Whether authentication was successful.
|
||||||
|
*/
|
||||||
|
signIn: (value: LogInUserRequest) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out the current user and clear authentication state.
|
||||||
|
* @returns {Promise<void>} Returns a promise that resolves when logout is complete.
|
||||||
|
*/
|
||||||
|
logOut: () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current authenticated user or null if no user is logged in.
|
||||||
|
* @type {IUser | null}
|
||||||
|
*/
|
||||||
|
user: IUser | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean flag indicating whether the user is authenticated.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the current user.
|
||||||
|
* @type {UserRolesType}
|
||||||
|
*/
|
||||||
|
role: UserRolesType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the authenticated user details.
|
||||||
|
* @interface IUser
|
||||||
|
*/
|
||||||
|
export interface IUser {
|
||||||
|
/**
|
||||||
|
* The username of the user.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authentication response containing the user's access token.
|
||||||
|
* @type {AuthResponse}
|
||||||
|
*/
|
||||||
|
accessToken: AuthResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the user.
|
||||||
|
* @type {UserRolesType}
|
||||||
|
*/
|
||||||
|
role: UserRolesType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Client-side mirror of the backend password policy (Identity defaults).
|
||||||
|
* Kept in one place so every password-setting screen (activation, reset,
|
||||||
|
* change password) validates the same rules and shows the same wording.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PasswordPolicyState {
|
||||||
|
requiredLength: boolean;
|
||||||
|
requireUppercase: boolean;
|
||||||
|
requireLowercase: boolean;
|
||||||
|
requireDigit: boolean;
|
||||||
|
requireNonAlphanumeric: boolean;
|
||||||
|
requiredUniqueChars: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPasswordPolicyState = (password: string): PasswordPolicyState => {
|
||||||
|
const uniqueCharsCount = new Set(password).size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
requiredLength: password.length >= 8,
|
||||||
|
requireUppercase: /[A-Z]/.test(password),
|
||||||
|
requireLowercase: /[a-z]/.test(password),
|
||||||
|
requireDigit: /\d/.test(password),
|
||||||
|
requireNonAlphanumeric: /[^a-zA-Z0-9]/.test(password),
|
||||||
|
requiredUniqueChars: uniqueCharsCount >= 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the list of human-readable validation messages for a new password and
|
||||||
|
* its confirmation. Returns an empty array when everything is valid.
|
||||||
|
*/
|
||||||
|
export const buildPasswordPolicyMessages = (
|
||||||
|
newPassword: string,
|
||||||
|
confirmPassword: string
|
||||||
|
): string[] => {
|
||||||
|
const policy = getPasswordPolicyState(newPassword);
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
messages.push('La nueva contraseña es obligatoria.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requiredLength) {
|
||||||
|
messages.push('La contraseña debe tener al menos 8 caracteres.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requireUppercase) {
|
||||||
|
messages.push('La contraseña debe contener al menos una letra mayúscula.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requireLowercase) {
|
||||||
|
messages.push('La contraseña debe contener al menos una letra minúscula.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requireDigit) {
|
||||||
|
messages.push('La contraseña debe contener al menos un número.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requireNonAlphanumeric) {
|
||||||
|
messages.push(
|
||||||
|
'La contraseña debe contener al menos un carácter no alfanumérico.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.requiredUniqueChars) {
|
||||||
|
messages.push('La contraseña debe contener al menos 2 caracteres únicos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
messages.push('La confirmación de contraseña es obligatoria.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword && confirmPassword && newPassword !== confirmPassword) {
|
||||||
|
messages.push('La confirmación no coincide con la nueva contraseña.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered checklist rendered under the password field so the user sees which
|
||||||
|
* rules they still need to satisfy.
|
||||||
|
*/
|
||||||
|
export const PASSWORD_POLICY_RULES: {
|
||||||
|
key: keyof PasswordPolicyState;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: 'requiredLength', label: 'Mínimo 8 caracteres' },
|
||||||
|
{ key: 'requireUppercase', label: 'Al menos una mayúscula' },
|
||||||
|
{ key: 'requireLowercase', label: 'Al menos una minúscula' },
|
||||||
|
{ key: 'requireDigit', label: 'Al menos un número' },
|
||||||
|
{ key: 'requireNonAlphanumeric', label: 'Al menos un carácter especial' },
|
||||||
|
{ key: 'requiredUniqueChars', label: 'Al menos 2 caracteres únicos' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { AxiosError, AxiosResponse } from 'axios';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useBackups } from '@/modules/backup/hook/backup.hook';
|
||||||
|
import { backupService } from '@/modules/backup/service/backup.service';
|
||||||
|
import type { IBackupRecordResponse } from '@/modules/backup/type/backup';
|
||||||
|
|
||||||
|
vi.mock('@/modules/backup/service/backup.service');
|
||||||
|
|
||||||
|
const mockedBackupService = vi.mocked(backupService, true);
|
||||||
|
|
||||||
|
const buildRecord = (
|
||||||
|
overrides: Partial<IBackupRecordResponse> = {}
|
||||||
|
): IBackupRecordResponse => ({
|
||||||
|
id: 'guid-1-aaaa-bbbb-cccc',
|
||||||
|
createdAt: '2026-08-19T10:00:00Z',
|
||||||
|
sizeBytes: 1024,
|
||||||
|
origin: 'Manual',
|
||||||
|
storagePath: 'backup-1.sql',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildResponse = <T,>(data: T, status = 200): AxiosResponse<T> =>
|
||||||
|
({
|
||||||
|
data,
|
||||||
|
status,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: {},
|
||||||
|
}) as AxiosResponse<T>;
|
||||||
|
|
||||||
|
const buildAxiosError = (status: number): AxiosError =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: `Request failed with status code ${status}`,
|
||||||
|
config: {},
|
||||||
|
response: buildResponse(undefined, status),
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as unknown as AxiosError;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useBackups — fetchBackups', () => {
|
||||||
|
it('starts with an empty list and not loading', () => {
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
expect(result.current.backups).toEqual([]);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets loading true during the fetch and populates backups on success', async () => {
|
||||||
|
const records = [buildRecord()];
|
||||||
|
let resolveFetch: (value: AxiosResponse<IBackupRecordResponse[]>) => void =
|
||||||
|
() => {};
|
||||||
|
mockedBackupService.getBackups.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.fetchBackups();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
resolveFetch(buildResponse(records));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.backups).toEqual(records);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears loading and leaves backups unchanged when the fetch fails', async () => {
|
||||||
|
mockedBackupService.getBackups.mockRejectedValueOnce(
|
||||||
|
buildAxiosError(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchBackups();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.backups).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useBackups — createBackup', () => {
|
||||||
|
it('sets busy during the request, refetches the catalog, and resolves true on success', async () => {
|
||||||
|
const created = buildRecord({ id: 'guid-new' });
|
||||||
|
let resolveCreate: (value: AxiosResponse<IBackupRecordResponse>) => void =
|
||||||
|
() => {};
|
||||||
|
mockedBackupService.createBackup.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// The server applies retention pruning, so the hook must re-read the
|
||||||
|
// authoritative catalog instead of optimistically prepending the new row.
|
||||||
|
mockedBackupService.getBackups.mockResolvedValue(buildResponse([created]));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
let createPromise: Promise<boolean>;
|
||||||
|
act(() => {
|
||||||
|
createPromise = result.current.createBackup();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.busy).toBe(true));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveCreate(buildResponse(created));
|
||||||
|
await createPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(createPromise!).resolves.toBe(true);
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
expect(mockedBackupService.getBackups).toHaveBeenCalled();
|
||||||
|
expect(result.current.backups).toEqual([created]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false and leaves the list unchanged when the server is busy (409)', async () => {
|
||||||
|
mockedBackupService.createBackup.mockRejectedValueOnce(
|
||||||
|
buildAxiosError(409)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
let created: boolean = true;
|
||||||
|
await act(async () => {
|
||||||
|
created = await result.current.createBackup();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toBe(false);
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
expect(result.current.backups).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useBackups — deleteBackup', () => {
|
||||||
|
it('removes the deleted record from the list and resolves true on success', async () => {
|
||||||
|
const record = buildRecord();
|
||||||
|
mockedBackupService.getBackups.mockResolvedValueOnce(
|
||||||
|
buildResponse([record])
|
||||||
|
);
|
||||||
|
mockedBackupService.deleteBackup.mockResolvedValueOnce(
|
||||||
|
buildResponse(undefined, 204)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchBackups();
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleted: boolean = false;
|
||||||
|
await act(async () => {
|
||||||
|
deleted = await result.current.deleteBackup(record.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(result.current.backups).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false and leaves the list unchanged when the record no longer exists (404)', async () => {
|
||||||
|
const record = buildRecord();
|
||||||
|
mockedBackupService.getBackups.mockResolvedValueOnce(
|
||||||
|
buildResponse([record])
|
||||||
|
);
|
||||||
|
mockedBackupService.deleteBackup.mockRejectedValueOnce(
|
||||||
|
buildAxiosError(404)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchBackups();
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleted: boolean = true;
|
||||||
|
await act(async () => {
|
||||||
|
deleted = await result.current.deleteBackup(record.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleted).toBe(false);
|
||||||
|
expect(result.current.backups).toEqual([record]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useBackups — restoreBackup', () => {
|
||||||
|
it('refetches the catalog after a successful restore and resolves true', async () => {
|
||||||
|
// A restore replays a full-schema dump, so the real catalog reverts to the
|
||||||
|
// restored snapshot's state — the hook must re-read it, never trust the
|
||||||
|
// pre-restore safety-backup record the endpoint returns.
|
||||||
|
const restoredCatalog = [buildRecord({ id: 'guid-from-snapshot' })];
|
||||||
|
mockedBackupService.restoreBackup.mockResolvedValueOnce(
|
||||||
|
buildResponse(buildRecord({ id: 'guid-safety', origin: 'Job' }))
|
||||||
|
);
|
||||||
|
mockedBackupService.getBackups.mockResolvedValueOnce(
|
||||||
|
buildResponse(restoredCatalog)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
let restored: boolean = false;
|
||||||
|
await act(async () => {
|
||||||
|
restored = await result.current.restoreBackup('guid-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(restored).toBe(true);
|
||||||
|
expect(mockedBackupService.getBackups).toHaveBeenCalled();
|
||||||
|
expect(result.current.backups).toEqual(restoredCatalog);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets busy true while in flight and resolves false on failure (500)', async () => {
|
||||||
|
let rejectRestore: (error: AxiosError) => void = () => {};
|
||||||
|
mockedBackupService.restoreBackup.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
rejectRestore = reject;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBackups());
|
||||||
|
|
||||||
|
let restorePromise: Promise<boolean>;
|
||||||
|
act(() => {
|
||||||
|
restorePromise = result.current.restoreBackup('guid-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.busy).toBe(true));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rejectRestore(buildAxiosError(500));
|
||||||
|
await restorePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(restorePromise!).resolves.toBe(false);
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
expect(result.current.backups).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { backupService } from '@/modules/backup/service/backup.service';
|
||||||
|
import { IBackupRecordResponse } from '@/modules/backup/type/backup';
|
||||||
|
|
||||||
|
export interface UseBackupsResult {
|
||||||
|
backups: IBackupRecordResponse[];
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
fetchBackups: () => Promise<void>;
|
||||||
|
createBackup: () => Promise<boolean>;
|
||||||
|
deleteBackup: (id: string) => Promise<boolean>;
|
||||||
|
restoreBackup: (id: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain state hook (not a context provider) owning the backup catalog for
|
||||||
|
* the admin panel. `BackupsTable` is currently its only consumer, so a
|
||||||
|
* shared context/provider would add indirection nothing else needs.
|
||||||
|
*/
|
||||||
|
export const useBackups = (): UseBackupsResult => {
|
||||||
|
const [backups, setBackups] = useState<IBackupRecordResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Pull the authoritative catalog from the server. Errors are swallowed: the
|
||||||
|
// caller keeps the previous list and page-level notify* handles messaging.
|
||||||
|
const refreshCatalog = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await backupService.getBackups();
|
||||||
|
setBackups(response.data);
|
||||||
|
} catch {
|
||||||
|
// keep the previous list
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBackups = useCallback(async (): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await refreshCatalog();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [refreshCatalog]);
|
||||||
|
|
||||||
|
const createBackup = useCallback(async (): Promise<boolean> => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await backupService.createBackup();
|
||||||
|
// Refetch, not an optimistic prepend: a manual backup applies server-side
|
||||||
|
// retention pruning, so the new row is not the only change to the catalog.
|
||||||
|
await refreshCatalog();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [refreshCatalog]);
|
||||||
|
|
||||||
|
const deleteBackup = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await backupService.deleteBackup(id);
|
||||||
|
setBackups(prev => prev.filter(backup => backup.id !== id));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreBackup = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await backupService.restoreBackup(id);
|
||||||
|
// Refetch, never optimistic: a restore replays a full-schema dump
|
||||||
|
// (BackupRecords included), so the catalog reverts to the restored
|
||||||
|
// snapshot's state — later backups, later deletions and the just-created
|
||||||
|
// pre-restore safety backup are all gone from the real table.
|
||||||
|
await refreshCatalog();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [refreshCatalog]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backups,
|
||||||
|
loading,
|
||||||
|
busy,
|
||||||
|
fetchBackups,
|
||||||
|
createBackup,
|
||||||
|
deleteBackup,
|
||||||
|
restoreBackup,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { sendDelete, sendGet, sendPost } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import { IBackupRecordResponse } from '@/modules/backup/type/backup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only tools for generating, listing, deleting and restoring database
|
||||||
|
* backups, plus the escape hatch for the maintenance-mode window a restore
|
||||||
|
* opens.
|
||||||
|
*/
|
||||||
|
export const backupService = {
|
||||||
|
/**
|
||||||
|
* Retrieves every catalogued backup, newest first.
|
||||||
|
* @returns {Promise<AxiosResponse<IBackupRecordResponse[]>>} The server response.
|
||||||
|
*/
|
||||||
|
getBackups: async (): Promise<AxiosResponse<IBackupRecordResponse[]>> =>
|
||||||
|
await sendGet(routes.backups),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an on-demand (manual) backup.
|
||||||
|
* @returns {Promise<AxiosResponse<IBackupRecordResponse>>} The server response containing the new backup.
|
||||||
|
*/
|
||||||
|
createBackup: async (): Promise<AxiosResponse<IBackupRecordResponse>> =>
|
||||||
|
await sendPost(routes.backups),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a catalogued backup by its ID.
|
||||||
|
* @param {string} id - The ID of the backup to delete.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} The server response.
|
||||||
|
*/
|
||||||
|
deleteBackup: async (id: string): Promise<AxiosResponse<void>> =>
|
||||||
|
await sendDelete(`${routes.backups}/${id}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the database from a catalogued backup. The server takes an
|
||||||
|
* automatic safety backup of the current state first and returns it.
|
||||||
|
* @param {string} id - The ID of the backup to restore from.
|
||||||
|
* @returns {Promise<AxiosResponse<IBackupRecordResponse>>} The server response containing the safety backup.
|
||||||
|
*/
|
||||||
|
restoreBackup: async (
|
||||||
|
id: string
|
||||||
|
): Promise<AxiosResponse<IBackupRecordResponse>> =>
|
||||||
|
await sendPost(`${routes.backups}/${id}/restore`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-exits maintenance mode, in case it is stuck active.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} The server response.
|
||||||
|
*/
|
||||||
|
exitMaintenance: async (): Promise<AxiosResponse<void>> =>
|
||||||
|
await sendDelete(routes.maintenance),
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* A single catalogued backup, as returned by the backend catalog. Mirrors
|
||||||
|
* `Application/DTOs/Backup/Response/BackupRecordResponse.cs`.
|
||||||
|
* @interface IBackupRecordResponse
|
||||||
|
*/
|
||||||
|
export interface IBackupRecordResponse {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the backup record.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO timestamp of when the backup was created (Fecha).
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size of the backup file in bytes (Peso).
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
sizeBytes: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the backup was created (Forma de creación): a manual on-demand
|
||||||
|
* request or an automated scheduled job.
|
||||||
|
* @type {'Manual' | 'Job'}
|
||||||
|
*/
|
||||||
|
origin: 'Manual' | 'Job';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage key used to locate the backup file.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
storagePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current maintenance-mode state, as returned by the backend. Mirrors
|
||||||
|
* `Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs`.
|
||||||
|
* @interface IMaintenanceStatusResponse
|
||||||
|
*/
|
||||||
|
export interface IMaintenanceStatusResponse {
|
||||||
|
/**
|
||||||
|
* Whether the database is currently in maintenance mode.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason maintenance mode was entered, if active.
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
|
reason: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO timestamp of when maintenance mode was entered, if active.
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
|
enteredAtUtc: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Spanish display labels for each `IBackupRecordResponse.origin` value,
|
||||||
|
* shown in the "Forma de creación" table column.
|
||||||
|
*/
|
||||||
|
export const BACKUP_ORIGIN_LABELS: Record<'Manual' | 'Job', string> = {
|
||||||
|
Manual: 'Manual',
|
||||||
|
Job: 'Programado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a byte count into a human-readable string (e.g. `1536` → `1.5 KB`),
|
||||||
|
* shown in the "Peso" table column.
|
||||||
|
* @param {number} bytes - The size in bytes.
|
||||||
|
* @returns {string} The formatted, human-readable size.
|
||||||
|
*/
|
||||||
|
export const formatBytes = (bytes: number): string => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponent = Math.min(
|
||||||
|
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||||
|
UNITS.length - 1
|
||||||
|
);
|
||||||
|
const value = bytes / Math.pow(1024, exponent);
|
||||||
|
// Round to 1 decimal, then drop a trailing ".0" so whole numbers (e.g.
|
||||||
|
// exactly 2 KB) read as "2 KB" instead of "2.0 KB".
|
||||||
|
const formattedValue =
|
||||||
|
exponent === 0 ? value.toString() : value.toFixed(1).replace(/\.0$/, '');
|
||||||
|
|
||||||
|
return `${formattedValue} ${UNITS[exponent]}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const BLOG_EXCERPT_LENGTH = 150;
|
||||||
|
export const BLOG_HOME_EXCERPT_LENGTH = 160;
|
||||||
|
|
||||||
|
// Well under the Slug column's 220-char limit (Slug is derived from Title
|
||||||
|
// and can get a "-2" suffix for uniqueness), so a max-length title can never
|
||||||
|
// overflow it.
|
||||||
|
export const BLOG_TITLE_MAX_LENGTH = 150;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { ErrorProvider } from '@/modules/error/context/error.context';
|
||||||
|
import { BlogPostProvider } from '@/modules/blogPost/context/blogPost.context';
|
||||||
|
import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook';
|
||||||
|
import { blogPostService } from '@/modules/blogPost/service/blogPost.service';
|
||||||
|
|
||||||
|
vi.mock('@/modules/blogPost/service/blogPost.service');
|
||||||
|
vi.mock('sweetalert2', () => ({
|
||||||
|
default: {
|
||||||
|
fire: vi.fn(),
|
||||||
|
getContainer: vi.fn().mockReturnValue(null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedAddBlogPost = vi.mocked(blogPostService.addBlogPost);
|
||||||
|
const mockedSwalFire = vi.mocked(Swal.fire);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ErrorProvider>
|
||||||
|
<BlogPostProvider>{children}</BlogPostProvider>
|
||||||
|
</ErrorProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BlogPostProvider — no duplicate success toast', () => {
|
||||||
|
/**
|
||||||
|
* addBlogPostForm.tsx already shows its own confirmation. The context used
|
||||||
|
* to ALSO fire a toast ("Blog Post created successfully" — also the only
|
||||||
|
* English string in these flows), so the user saw two modals for one save.
|
||||||
|
*/
|
||||||
|
it('does not fire its own toast after addBlogPost succeeds', async () => {
|
||||||
|
mockedAddBlogPost.mockResolvedValueOnce({
|
||||||
|
status: 201,
|
||||||
|
data: { id: '77777777-7777-7777-7777-777777777777' },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBlogPost(), { wrapper });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.addBlogPost({} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedSwalFire).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { AxiosError, AxiosResponse } from 'axios';
|
||||||
|
import React, { createContext, ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FetchOptions,
|
||||||
|
GenericResponsePagination,
|
||||||
|
GUID,
|
||||||
|
} from '@/modules/core/types/types';
|
||||||
|
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
|
||||||
|
import { blogPostService } from '@/modules/blogPost/service/blogPost.service';
|
||||||
|
import {
|
||||||
|
BlogPostResponse,
|
||||||
|
CreateBlogPostRequest,
|
||||||
|
GetBlogPostsFilteredRequest,
|
||||||
|
IBlogPostContextProps,
|
||||||
|
UpdateBlogPostRequest,
|
||||||
|
} from '@/modules/blogPost/type/blogPost';
|
||||||
|
import { blogPostKeys } from '@/modules/blogPost/queryKeys';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
|
||||||
|
export const BlogPostContext = createContext<IBlogPostContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BlogPostProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleUnknownError = useUnknownErrorHandler();
|
||||||
|
|
||||||
|
const addBlogPostMutation = useMutation({
|
||||||
|
mutationFn: blogPostService.addBlogPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
const putBlogPostMutation = useMutation({
|
||||||
|
mutationFn: ({ id, post }: { id: GUID; post: UpdateBlogPostRequest }) =>
|
||||||
|
blogPostService.putBlogPostById(id, post),
|
||||||
|
});
|
||||||
|
|
||||||
|
const putPhotoBlogPostMutation = useMutation({
|
||||||
|
mutationFn: ({ id, photo }: { id: GUID; photo: File }) =>
|
||||||
|
blogPostService.putPhotoBlogPostById(id, photo),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBlogPostMutation = useMutation({
|
||||||
|
mutationFn: blogPostService.deleteBlogPostById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addBlogPost = useCallback(
|
||||||
|
async (post: CreateBlogPostRequest): Promise<BlogPostResponse | void> => {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<BlogPostResponse> =
|
||||||
|
await addBlogPostMutation.mutateAsync(post);
|
||||||
|
|
||||||
|
if (response && response.data) {
|
||||||
|
// Success feedback belongs to the calling page (addBlogPostForm.tsx
|
||||||
|
// shows its own confirmation) — a toast here too means two modals.
|
||||||
|
queryClient.setQueryData(
|
||||||
|
blogPostKeys.byId(response.data.id),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: blogPostKeys.list(),
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AxiosError(
|
||||||
|
'Respuesta del servidor inválida.',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
response
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addBlogPostMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a blog post by id. When the response is 204 No Content there is
|
||||||
|
* no body to cache — the invalidation below is enough to refresh callers.
|
||||||
|
*/
|
||||||
|
const putBlogPostById = useCallback(
|
||||||
|
async (
|
||||||
|
id: GUID,
|
||||||
|
post: UpdateBlogPostRequest
|
||||||
|
): Promise<BlogPostResponse | void> => {
|
||||||
|
try {
|
||||||
|
const response = await putBlogPostMutation.mutateAsync({ id, post });
|
||||||
|
if (response) {
|
||||||
|
if (response.status === HttpStatus.NoContent) {
|
||||||
|
// no-op
|
||||||
|
} else if (response.data) {
|
||||||
|
queryClient.setQueryData(blogPostKeys.byId(id), response);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: blogPostKeys.list(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[putBlogPostMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const putPhotoBlogPostById = useCallback(
|
||||||
|
async (id: GUID, photo: File): Promise<BlogPostResponse | void> => {
|
||||||
|
try {
|
||||||
|
await putPhotoBlogPostMutation.mutateAsync({ id, photo });
|
||||||
|
// The photo endpoint returns no body and each upload lands at a new
|
||||||
|
// unique URL, so the fresh photoUrl is only knowable via a real GET —
|
||||||
|
// mirrors venue.context.tsx's putVenuePhotoById fix for the same gap.
|
||||||
|
const res: AxiosResponse<BlogPostResponse> =
|
||||||
|
await blogPostService.getBlogPostsById(id);
|
||||||
|
queryClient.setQueryData(blogPostKeys.byId(id), res);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() });
|
||||||
|
return res.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[putPhotoBlogPostMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBlogPostsById = useCallback(
|
||||||
|
async (
|
||||||
|
idOrSlug: string,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<BlogPostResponse | void> => {
|
||||||
|
try {
|
||||||
|
const response = await queryClient.fetchQuery({
|
||||||
|
queryKey: blogPostKeys.byId(idOrSlug),
|
||||||
|
queryFn: async () => await blogPostService.getBlogPostsById(idOrSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!options?.silent) handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBlogPostsByFilters = useCallback(
|
||||||
|
async (
|
||||||
|
filter: GetBlogPostsFilteredRequest,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<GenericResponsePagination<BlogPostResponse> | void> => {
|
||||||
|
try {
|
||||||
|
const response = await queryClient.fetchQuery({
|
||||||
|
queryKey: blogPostKeys.list(filter),
|
||||||
|
queryFn: async () =>
|
||||||
|
await blogPostService.getBlogPostsByFilters(filter),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!options?.silent) handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteBlogPostById = useCallback(
|
||||||
|
async (id: GUID): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await deleteBlogPostMutation.mutateAsync(id);
|
||||||
|
queryClient.removeQueries({ queryKey: blogPostKeys.byId(id) });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() });
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteBlogPostMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const container: IBlogPostContextProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
addBlogPost,
|
||||||
|
putBlogPostById,
|
||||||
|
putPhotoBlogPostById,
|
||||||
|
getBlogPostsById,
|
||||||
|
getBlogPostsByFilters,
|
||||||
|
deleteBlogPostById,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
addBlogPost,
|
||||||
|
putBlogPostById,
|
||||||
|
putPhotoBlogPostById,
|
||||||
|
getBlogPostsById,
|
||||||
|
getBlogPostsByFilters,
|
||||||
|
deleteBlogPostById,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<BlogPostContext.Provider value={container}>
|
||||||
|
{children}
|
||||||
|
</BlogPostContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { BlogPostContext } from '@/modules/blogPost/context/blogPost.context';
|
||||||
|
|
||||||
|
export const useBlogPost = () => {
|
||||||
|
const context = useContext(BlogPostContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useBlogPost must be used within a BlogPostProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { blogPostKeys } from './queryKeys';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost';
|
||||||
|
|
||||||
|
describe('blogPostKeys', () => {
|
||||||
|
const id: GUID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
it('list() returns the bare list literal with no trailing undefined', () => {
|
||||||
|
expect(blogPostKeys.list()).toEqual(['blogPost', 'list']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list(filter) returns the filtered list literal', () => {
|
||||||
|
const filter: GetBlogPostsFilteredRequest = {
|
||||||
|
author: 'jane',
|
||||||
|
pageNumber: 1,
|
||||||
|
};
|
||||||
|
expect(blogPostKeys.list(filter)).toEqual(['blogPost', 'list', filter]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('byId(id) returns the by-id literal', () => {
|
||||||
|
expect(blogPostKeys.byId(id)).toEqual(['blogPost', 'byId', id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost';
|
||||||
|
|
||||||
|
export const blogPostKeys = {
|
||||||
|
list: (filter?: GetBlogPostsFilteredRequest) =>
|
||||||
|
filter === undefined
|
||||||
|
? (['blogPost', 'list'] as const)
|
||||||
|
: (['blogPost', 'list', filter] as const),
|
||||||
|
byId: (idOrSlug: string) => ['blogPost', 'byId', idOrSlug] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { withTablePageSize } from '@/modules/core/constants/pagination';
|
||||||
|
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
|
||||||
|
import {
|
||||||
|
sendDelete,
|
||||||
|
sendGet,
|
||||||
|
sendPost,
|
||||||
|
sendPut,
|
||||||
|
} from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
BlogPostResponse,
|
||||||
|
CreateBlogPostRequest,
|
||||||
|
GetBlogPostsFilteredRequest,
|
||||||
|
UpdateBlogPostRequest,
|
||||||
|
} from '@/modules/blogPost/type/blogPost';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogPostService provides methods to interact with the blog posts API.
|
||||||
|
*/
|
||||||
|
export const blogPostService = {
|
||||||
|
/**
|
||||||
|
* Adds a new blog post.
|
||||||
|
* @param {CreateBlogPostRequest} post - The post data to be added.
|
||||||
|
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
addBlogPost: (
|
||||||
|
post: CreateBlogPostRequest
|
||||||
|
): Promise<AxiosResponse<BlogPostResponse>> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('Author', post.author);
|
||||||
|
formData.append('Title', post.title);
|
||||||
|
formData.append('MarkdownText', post.markdownText);
|
||||||
|
|
||||||
|
// HU-16: only override the server default (published) when the author
|
||||||
|
// explicitly chose a draft, keeping the create request backward-compatible.
|
||||||
|
if (post.isPublished !== undefined) {
|
||||||
|
formData.append('IsPublished', String(post.isPublished));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.photoFile) {
|
||||||
|
formData.append('PhotoFile', post.photoFile as Blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendPost<BlogPostResponse>(routes.blogposts, formData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing blog post by its ID.
|
||||||
|
* @param {string} id - The ID of the blog post to be updated.
|
||||||
|
* @param {UpdateBlogPostRequest} post - The updated post data.
|
||||||
|
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
putBlogPostById: async (
|
||||||
|
id: GUID,
|
||||||
|
post: UpdateBlogPostRequest
|
||||||
|
): Promise<AxiosResponse<BlogPostResponse>> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (post.author) formData.append('Author', post.author);
|
||||||
|
|
||||||
|
if (post.title) formData.append('Title', post.title);
|
||||||
|
|
||||||
|
if (post.markdownText) formData.append('MarkdownText', post.markdownText);
|
||||||
|
|
||||||
|
// HU-16: forward the publication state only when the caller set it, so an
|
||||||
|
// edit that does not touch the draft/published toggle leaves it unchanged.
|
||||||
|
if (post.isPublished !== undefined) {
|
||||||
|
formData.append('IsPublished', String(post.isPublished));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendPut<BlogPostResponse>(`${routes.blogposts}/${id}`, formData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the photo of an existing blog post by its ID.
|
||||||
|
* @param {string} id - The ID of the blog post.
|
||||||
|
* @param {File} photo - The new photo file to be uploaded.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
putPhotoBlogPostById: async (
|
||||||
|
id: GUID,
|
||||||
|
photo: File
|
||||||
|
): Promise<AxiosResponse<void>> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('PhotoFile', photo);
|
||||||
|
|
||||||
|
return sendPut<void>(`${routes.blogposts}/${id}/photo`, formData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a blog post by its ID or its public slug.
|
||||||
|
* @param {string} idOrSlug - The ID or slug of the blog post to retrieve.
|
||||||
|
* @returns {Promise<AxiosResponse<BlogPostResponse>>} - A promise that resolves with the blog post data.
|
||||||
|
*/
|
||||||
|
getBlogPostsById: async (
|
||||||
|
idOrSlug: string
|
||||||
|
): Promise<AxiosResponse<BlogPostResponse>> =>
|
||||||
|
sendGet<BlogPostResponse>(`${routes.blogposts}/${idOrSlug}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches blog posts based on filters and pagination.
|
||||||
|
* @param filter The filter criteria to apply when fetching blog posts.
|
||||||
|
* @returns A promise that resolves with a paginated response containing filtered blog posts.
|
||||||
|
*/
|
||||||
|
getBlogPostsByFilters: async (
|
||||||
|
filter: GetBlogPostsFilteredRequest
|
||||||
|
): Promise<AxiosResponse<GenericResponsePagination<BlogPostResponse>>> =>
|
||||||
|
sendGet<GenericResponsePagination<BlogPostResponse>>(
|
||||||
|
routes.blogposts,
|
||||||
|
withTablePageSize(filter)
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a blog post by its ID.
|
||||||
|
* @param {string} id - The ID of the blog post to delete.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} - A promise that resolves when the blog post is deleted.
|
||||||
|
*/
|
||||||
|
deleteBlogPostById: async (id: GUID): Promise<AxiosResponse<void>> =>
|
||||||
|
sendDelete<void>(`${routes.blogposts}/${id}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
FetchOptions,
|
||||||
|
Filtered,
|
||||||
|
GenericResponsePagination,
|
||||||
|
} from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context properties and methods for managing blog posts in a React application.
|
||||||
|
* These methods interact with the backend for creating, updating, fetching, and deleting blog posts.
|
||||||
|
* @interface IBlogPostContextProps
|
||||||
|
*/
|
||||||
|
export interface IBlogPostContextProps {
|
||||||
|
/**
|
||||||
|
* Adds a new blog post.
|
||||||
|
* @param post The details of the blog post to add.
|
||||||
|
* @returns A promise that resolves with the response containing the newly added blog post.
|
||||||
|
*/
|
||||||
|
addBlogPost(post: CreateBlogPostRequest): Promise<BlogPostResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing blog post by its ID.
|
||||||
|
* @param id The ID of the blog post to update.
|
||||||
|
* @param post The updated blog post data.
|
||||||
|
* @returns A promise that resolves with the response containing the updated blog post.
|
||||||
|
*/
|
||||||
|
putBlogPostById(
|
||||||
|
id: GUID,
|
||||||
|
post: UpdateBlogPostRequest
|
||||||
|
): Promise<BlogPostResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the photo of an existing blog post by its ID.
|
||||||
|
* @param id The ID of the blog post to update the photo for.
|
||||||
|
* @param photo The new photo file to upload.
|
||||||
|
* @returns A promise that resolves when the photo is successfully updated.
|
||||||
|
*/
|
||||||
|
putPhotoBlogPostById(id: GUID, photo: File): Promise<BlogPostResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a blog post by its ID or its public slug.
|
||||||
|
* @param idOrSlug The ID or slug of the blog post to fetch.
|
||||||
|
* @returns A promise that resolves with the blog post data.
|
||||||
|
*/
|
||||||
|
getBlogPostsById(
|
||||||
|
idOrSlug: string,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<BlogPostResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches blog posts based on filters and pagination.
|
||||||
|
* @param filter The filter criteria to apply when fetching blog posts.
|
||||||
|
* @param options Per-call options; `silent` suppresses the global alert on failure.
|
||||||
|
* @returns A promise that resolves with a paginated response containing filtered blog posts.
|
||||||
|
*/
|
||||||
|
getBlogPostsByFilters(
|
||||||
|
filter: GetBlogPostsFilteredRequest,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<GenericResponsePagination<BlogPostResponse> | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a blog post by its ID.
|
||||||
|
* @param id The ID of the blog post to delete.
|
||||||
|
* @returns A promise resolving to `true` if the blog post was deleted,
|
||||||
|
* `false` if the request failed (the global error is already reported
|
||||||
|
* either way).
|
||||||
|
*/
|
||||||
|
deleteBlogPostById(id: GUID): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for adding a new blog post.
|
||||||
|
* @interface CreateBlogPostRequest
|
||||||
|
*/
|
||||||
|
export interface CreateBlogPostRequest {
|
||||||
|
/**
|
||||||
|
* The author of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
author: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The photo file to upload for the blog post (optional).
|
||||||
|
* @type {File}
|
||||||
|
*/
|
||||||
|
photoFile?: File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The markdown text content of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
markdownText: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the post is published (visible publicly) or saved as a draft
|
||||||
|
* (HU-16). Defaults to published when omitted.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isPublished?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for updating an existing blog post.
|
||||||
|
* @interface UpdateBlogPostRequest
|
||||||
|
*/
|
||||||
|
export interface UpdateBlogPostRequest {
|
||||||
|
/**
|
||||||
|
* The updated title of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated markdown text content of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
markdownText?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated author of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated publication state (HU-16). `undefined` leaves the current
|
||||||
|
* state untouched; `true` publishes, `false` turns it back into a draft.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isPublished?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for updating the photo of an existing blog post.
|
||||||
|
* @interface UpdateBlogPostPhotoRequest
|
||||||
|
*/
|
||||||
|
export interface UpdateBlogPostPhotoRequest {
|
||||||
|
/**
|
||||||
|
* The file of the blog post photo to be updated.
|
||||||
|
* @type {File}
|
||||||
|
*/
|
||||||
|
photoFile: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response structure for a blog post, including its details and metadata.
|
||||||
|
* @interface BlogPostResponse
|
||||||
|
*/
|
||||||
|
export interface BlogPostResponse {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
id: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
author: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique, URL-friendly identifier used in public blog post links.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of views the blog post has received.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
views: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the photo associated with the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
photoUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The markdown text content of the blog post.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
markdownText: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date and time the blog post was created.
|
||||||
|
* @type {Date}
|
||||||
|
*/
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the post is published (visible publicly) or a draft (HU-16).
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isPublished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for fetching filtered blog posts with pagination.
|
||||||
|
* @interface GetBlogPostsFilteredRequest
|
||||||
|
*/
|
||||||
|
export interface GetBlogPostsFilteredRequest extends Filtered {
|
||||||
|
/**
|
||||||
|
* The author to filter blog posts by.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title to filter blog posts by.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { sendGet } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
IChampionHistory,
|
||||||
|
IPodium,
|
||||||
|
} from '@/modules/champion/type/champion.d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for public champions/podium data (read-only).
|
||||||
|
*/
|
||||||
|
export const championService = {
|
||||||
|
/**
|
||||||
|
* Fetches the podium (top three per division) of a tournament by its id or
|
||||||
|
* public slug. Returns one entry per division; a place is `null` until it is
|
||||||
|
* decided.
|
||||||
|
* @param {string} idOrSlug - Tournament id or slug.
|
||||||
|
* @returns {Promise<AxiosResponse<IPodium[]>>} The server response.
|
||||||
|
*/
|
||||||
|
getTournamentChampions: async (
|
||||||
|
idOrSlug: string
|
||||||
|
): Promise<AxiosResponse<IPodium[]>> =>
|
||||||
|
sendGet(`${routes.tournaments}/${idOrSlug}/champions`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the public champions history (finished tournaments only),
|
||||||
|
* optionally scoped to a single season.
|
||||||
|
* @param {GUID} [seasonId] - Optional season to filter by.
|
||||||
|
* @returns {Promise<AxiosResponse<IChampionHistory[]>>} The server response.
|
||||||
|
*/
|
||||||
|
getChampionsHistory: async (
|
||||||
|
seasonId?: GUID
|
||||||
|
): Promise<AxiosResponse<IChampionHistory[]>> =>
|
||||||
|
sendGet(routes.champions, seasonId ? { seasonId } : undefined),
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single team occupying a podium place. Mirrors the backend's minimal team
|
||||||
|
* projection (id + display name + optional logo) used for champions/podium.
|
||||||
|
*/
|
||||||
|
export interface IPodiumTeam {
|
||||||
|
teamId: GUID;
|
||||||
|
teamName: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-three finish of a single division (HU-Champions). `first`/`second`/
|
||||||
|
* `third` are `null` until that place is decided. `hasPlayoff` distinguishes a
|
||||||
|
* podium crowned by a playoff bracket from one read straight off the final
|
||||||
|
* standings — both are valid top-threes.
|
||||||
|
*/
|
||||||
|
export interface IPodium {
|
||||||
|
divisionId: GUID;
|
||||||
|
divisionName: string;
|
||||||
|
hasPlayoff: boolean;
|
||||||
|
first: IPodiumTeam | null;
|
||||||
|
second: IPodiumTeam | null;
|
||||||
|
third: IPodiumTeam | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single champion entry in the public history (only finished tournaments).
|
||||||
|
* `category` is the raw backend enum name ("Masculine"/"Feminine") — display it
|
||||||
|
* through `TOURNAMENT_CATEGORY_LABELS`.
|
||||||
|
*/
|
||||||
|
export interface IChampionHistory {
|
||||||
|
tournamentId: GUID;
|
||||||
|
tournamentName: string;
|
||||||
|
seasonName: string | null;
|
||||||
|
/**
|
||||||
|
* The calendar year of the season, or null when there is no season or it
|
||||||
|
* has no year. The public page sorts seasons by this value, newest first.
|
||||||
|
*/
|
||||||
|
seasonYear: number | null;
|
||||||
|
category: TournamentCategory;
|
||||||
|
divisionName: string;
|
||||||
|
/**
|
||||||
|
* The sub-cup (playoff bracket) that was won, e.g. "Copa Oro" / "Copa Plata",
|
||||||
|
* when the division splits its playoff into tiers. Null when the division
|
||||||
|
* crowns a single champion (a single bracket or a group-only division).
|
||||||
|
*/
|
||||||
|
cupName: string | null;
|
||||||
|
championTeam: IPodiumTeam;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { IChampionHistory } from '@/modules/champion/type/champion.d';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { groupChampions } from './groupChampions';
|
||||||
|
|
||||||
|
const guid = (value: string) => value as GUID;
|
||||||
|
|
||||||
|
const entry = (overrides: Partial<IChampionHistory> = {}): IChampionHistory => ({
|
||||||
|
tournamentId: guid('tournament-1'),
|
||||||
|
tournamentName: 'Apertura 2025',
|
||||||
|
seasonName: 'Temporada 2025',
|
||||||
|
seasonYear: 2025,
|
||||||
|
category: TournamentCategory.Masculine,
|
||||||
|
divisionName: 'Zona A',
|
||||||
|
cupName: null,
|
||||||
|
championTeam: {
|
||||||
|
teamId: guid('team-1'),
|
||||||
|
teamName: 'Los Halcones',
|
||||||
|
logoUrl: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupChampions', () => {
|
||||||
|
it('returns an empty array for empty history', () => {
|
||||||
|
expect(groupChampions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders seasons by year, newest first', () => {
|
||||||
|
const result = groupChampions([
|
||||||
|
entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }),
|
||||||
|
entry({ seasonName: 'Temporada 2026', seasonYear: 2026 }),
|
||||||
|
entry({ seasonName: 'Temporada 2024', seasonYear: 2024 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.map(season => season.seasonName)).toEqual([
|
||||||
|
'Temporada 2026',
|
||||||
|
'Temporada 2025',
|
||||||
|
'Temporada 2024',
|
||||||
|
]);
|
||||||
|
expect(result.map(season => season.seasonYear)).toEqual([2026, 2025, 2024]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts null-year seasons (including "Sin temporada") last', () => {
|
||||||
|
const result = groupChampions([
|
||||||
|
entry({ seasonName: null, seasonYear: null }),
|
||||||
|
entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.map(season => season.seasonName)).toEqual([
|
||||||
|
'Temporada 2025',
|
||||||
|
'Sin temporada',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets null or empty seasons under "Sin temporada"', () => {
|
||||||
|
const result = groupChampions([
|
||||||
|
entry({ seasonName: null, seasonYear: null }),
|
||||||
|
entry({ seasonName: '', seasonYear: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].seasonName).toBe('Sin temporada');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests Season -> Tournament -> Division, carrying the category on the tournament', () => {
|
||||||
|
const result = groupChampions([
|
||||||
|
entry({
|
||||||
|
tournamentId: guid('t-masc'),
|
||||||
|
tournamentName: 'Apertura Masculino',
|
||||||
|
category: TournamentCategory.Masculine,
|
||||||
|
divisionName: 'Zona A',
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
tournamentId: guid('t-fem'),
|
||||||
|
tournamentName: 'Apertura Femenino',
|
||||||
|
category: TournamentCategory.Feminine,
|
||||||
|
divisionName: 'Zona Única',
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
tournamentId: guid('t-masc'),
|
||||||
|
tournamentName: 'Apertura Masculino',
|
||||||
|
category: TournamentCategory.Masculine,
|
||||||
|
divisionName: 'Zona B',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const [season] = result;
|
||||||
|
expect(season.tournaments.map(t => t.tournamentName)).toEqual([
|
||||||
|
'Apertura Masculino',
|
||||||
|
'Apertura Femenino',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const masc = season.tournaments.find(t => t.tournamentId === guid('t-masc'));
|
||||||
|
expect(masc?.category).toBe(TournamentCategory.Masculine);
|
||||||
|
expect(masc?.divisions.map(d => d.divisionName)).toEqual(['Zona A', 'Zona B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every sub-cup champion of a division, in backend (tier) order', () => {
|
||||||
|
const result = groupChampions([
|
||||||
|
entry({ divisionName: 'Primera', cupName: 'Copa Oro', championTeam: {
|
||||||
|
teamId: guid('gold'), teamName: 'Oro FC', logoUrl: null,
|
||||||
|
} }),
|
||||||
|
entry({ divisionName: 'Primera', cupName: 'Copa Plata', championTeam: {
|
||||||
|
teamId: guid('silver'), teamName: 'Plata FC', logoUrl: null,
|
||||||
|
} }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const division = result[0].tournaments[0].divisions[0];
|
||||||
|
expect(division.divisionName).toBe('Primera');
|
||||||
|
expect(division.entries.map(e => e.cupName)).toEqual(['Copa Oro', 'Copa Plata']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { IChampionHistory } from '@/modules/champion/type/champion.d';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
/** A division bucket inside a tournament, holding its per-cup champions in order. */
|
||||||
|
export interface ChampionDivisionGroup {
|
||||||
|
divisionName: string;
|
||||||
|
entries: IChampionHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tournament bucket inside a season, holding its divisions. Carries the
|
||||||
|
* tournament's category so the view can badge it. */
|
||||||
|
export interface ChampionTournamentGroup {
|
||||||
|
tournamentId: GUID;
|
||||||
|
tournamentName: string;
|
||||||
|
category: TournamentCategory;
|
||||||
|
divisions: ChampionDivisionGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A season bucket holding its tournaments. */
|
||||||
|
export interface ChampionSeasonGroup {
|
||||||
|
seasonName: string;
|
||||||
|
/** Calendar year of the season; null for "Sin temporada" or a year-less season. */
|
||||||
|
seasonYear: number | null;
|
||||||
|
tournaments: ChampionTournamentGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback label for tournaments not yet assigned to a season. */
|
||||||
|
const NO_SEASON_LABEL = 'Sin temporada';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes the flat champion history into the public page's hierarchy:
|
||||||
|
* Season → Tournament → Division → per-cup champion entries. Seasons are
|
||||||
|
* ordered by year, newest first; a year-less season (including the single
|
||||||
|
* "Sin temporada" bucket for entries with a null/empty season) sorts last,
|
||||||
|
* with `seasonName` descending as the deterministic tiebreak. Within a season
|
||||||
|
* the tournament, division and per-cup order follows the backend's
|
||||||
|
* first-appearance order, so the within-division entries keep the backend's
|
||||||
|
* tier order (Copa Oro before Copa Plata). A tournament already implies its
|
||||||
|
* category, so category is carried on the tournament rather than used as a
|
||||||
|
* grouping level.
|
||||||
|
*/
|
||||||
|
export const groupChampions = (
|
||||||
|
history: IChampionHistory[]
|
||||||
|
): ChampionSeasonGroup[] => {
|
||||||
|
const seasonOrder: string[] = [];
|
||||||
|
const bySeason = new Map<string, ChampionSeasonGroup>();
|
||||||
|
|
||||||
|
history.forEach(entry => {
|
||||||
|
const seasonKey = entry.seasonName || NO_SEASON_LABEL;
|
||||||
|
|
||||||
|
let season = bySeason.get(seasonKey);
|
||||||
|
if (!season) {
|
||||||
|
season = {
|
||||||
|
seasonName: seasonKey,
|
||||||
|
seasonYear: entry.seasonName ? entry.seasonYear : null,
|
||||||
|
tournaments: [],
|
||||||
|
};
|
||||||
|
bySeason.set(seasonKey, season);
|
||||||
|
seasonOrder.push(seasonKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tournament = season.tournaments.find(
|
||||||
|
t => t.tournamentId === entry.tournamentId
|
||||||
|
);
|
||||||
|
if (!tournament) {
|
||||||
|
tournament = {
|
||||||
|
tournamentId: entry.tournamentId,
|
||||||
|
tournamentName: entry.tournamentName,
|
||||||
|
category: entry.category,
|
||||||
|
divisions: [],
|
||||||
|
};
|
||||||
|
season.tournaments.push(tournament);
|
||||||
|
}
|
||||||
|
|
||||||
|
let division = tournament.divisions.find(
|
||||||
|
d => d.divisionName === entry.divisionName
|
||||||
|
);
|
||||||
|
if (!division) {
|
||||||
|
division = { divisionName: entry.divisionName, entries: [] };
|
||||||
|
tournament.divisions.push(division);
|
||||||
|
}
|
||||||
|
|
||||||
|
division.entries.push(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
return seasonOrder
|
||||||
|
.map(seasonName => bySeason.get(seasonName)!)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Year desc; a null year always sorts after a real one.
|
||||||
|
if (a.seasonYear !== b.seasonYear) {
|
||||||
|
if (a.seasonYear === null) return 1;
|
||||||
|
if (b.seasonYear === null) return -1;
|
||||||
|
return b.seasonYear - a.seasonYear;
|
||||||
|
}
|
||||||
|
// Same year (or both null): name desc, for a deterministic order.
|
||||||
|
return b.seasonName.localeCompare(a.seasonName);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
|
||||||
|
import { clubService } from '@/modules/club/service/club.service';
|
||||||
|
import { clubKeys } from '@/modules/club/queryKeys';
|
||||||
|
import {
|
||||||
|
IClubContextProps,
|
||||||
|
IClubHistoryResponse,
|
||||||
|
IClubSummaryResponse,
|
||||||
|
IRosterCopyRequest,
|
||||||
|
IRosterCopyResult,
|
||||||
|
} from '@/modules/club/type/club.d';
|
||||||
|
|
||||||
|
export const ClubContext = createContext<IClubContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClubProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [club, setClub] = useState<IClubHistoryResponse | null>(null);
|
||||||
|
const [allClubs, setAllClubs] = useState<IClubSummaryResponse[]>([]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const handleUnknownError = useUnknownErrorHandler();
|
||||||
|
|
||||||
|
const copyRosterMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
targetTeamId,
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
targetTeamId: GUID;
|
||||||
|
request: IRosterCopyRequest;
|
||||||
|
}) => clubService.copyRoster(targetTeamId, request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkClubParentMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
childClubId,
|
||||||
|
parentClubId,
|
||||||
|
}: {
|
||||||
|
childClubId: GUID;
|
||||||
|
parentClubId: GUID;
|
||||||
|
}) => clubService.linkClubParent(childClubId, parentClubId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlinkClubParentMutation = useMutation({
|
||||||
|
mutationFn: (childClubId: GUID) => clubService.unlinkClubParent(childClubId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameClubMutation = useMutation({
|
||||||
|
mutationFn: ({ clubId, name }: { clubId: GUID; name: string }) =>
|
||||||
|
clubService.renameClub(clubId, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteClubMutation = useMutation({
|
||||||
|
mutationFn: (clubId: GUID) => clubService.deleteClub(clubId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getClubHistory = useCallback(
|
||||||
|
async (idOrSlug: string): Promise<IClubHistoryResponse | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IClubHistoryResponse> =
|
||||||
|
await queryClient.fetchQuery({
|
||||||
|
queryKey: clubKeys.history(idOrSlug),
|
||||||
|
queryFn: async () => await clubService.getClubHistory(idOrSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
setClub(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyRoster = useCallback(
|
||||||
|
async (
|
||||||
|
targetTeamId: GUID,
|
||||||
|
request: IRosterCopyRequest
|
||||||
|
): Promise<IRosterCopyResult | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IRosterCopyResult> =
|
||||||
|
await copyRosterMutation.mutateAsync({ targetTeamId, request });
|
||||||
|
return res?.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[copyRosterMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getAllClubs = useCallback(async (): Promise<
|
||||||
|
IClubSummaryResponse[] | void
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IClubSummaryResponse[]> =
|
||||||
|
await queryClient.fetchQuery({
|
||||||
|
queryKey: clubKeys.all(),
|
||||||
|
queryFn: async () => await clubService.getAllClubs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
setAllClubs(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
}, [queryClient, handleUnknownError]);
|
||||||
|
|
||||||
|
const linkClubParent = useCallback(
|
||||||
|
async (
|
||||||
|
childClubId: GUID,
|
||||||
|
parentClubId: GUID
|
||||||
|
): Promise<IClubHistoryResponse | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IClubHistoryResponse> =
|
||||||
|
await linkClubParentMutation.mutateAsync({
|
||||||
|
childClubId,
|
||||||
|
parentClubId,
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
setClub(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[linkClubParentMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlinkClubParent = useCallback(
|
||||||
|
async (childClubId: GUID): Promise<IClubHistoryResponse | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IClubHistoryResponse> =
|
||||||
|
await unlinkClubParentMutation.mutateAsync(childClubId);
|
||||||
|
if (res) {
|
||||||
|
setClub(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unlinkClubParentMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameClub = useCallback(
|
||||||
|
async (clubId: GUID, name: string): Promise<IClubHistoryResponse | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IClubHistoryResponse> =
|
||||||
|
await renameClubMutation.mutateAsync({ clubId, name });
|
||||||
|
if (res) {
|
||||||
|
setClub(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[renameClubMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteClub = useCallback(
|
||||||
|
async (clubId: GUID): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await deleteClubMutation.mutateAsync(clubId);
|
||||||
|
setAllClubs(prev => prev.filter(candidate => candidate.id !== clubId));
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteClubMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const container: IClubContextProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
club,
|
||||||
|
getClubHistory,
|
||||||
|
copyRoster,
|
||||||
|
allClubs,
|
||||||
|
getAllClubs,
|
||||||
|
linkClubParent,
|
||||||
|
unlinkClubParent,
|
||||||
|
renameClub,
|
||||||
|
deleteClub,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
club,
|
||||||
|
getClubHistory,
|
||||||
|
copyRoster,
|
||||||
|
allClubs,
|
||||||
|
getAllClubs,
|
||||||
|
linkClubParent,
|
||||||
|
unlinkClubParent,
|
||||||
|
renameClub,
|
||||||
|
deleteClub,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClubContext.Provider value={container}>{children}</ClubContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ClubContext } from '@/modules/club/context/club.context';
|
||||||
|
|
||||||
|
export const useClub = () => {
|
||||||
|
const context = useContext(ClubContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useClub must be used within a ClubProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const clubKeys = {
|
||||||
|
history: (idOrSlug: string) => ['club', 'history', idOrSlug] as const,
|
||||||
|
all: () => ['club', 'all'] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { sendDelete, sendGet, sendPost, sendPut } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
IClubHistoryResponse,
|
||||||
|
IClubSummaryResponse,
|
||||||
|
IRosterCopyRequest,
|
||||||
|
IRosterCopyResult,
|
||||||
|
} from '@/modules/club/type/club.d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for the stable cross-season club identity (HU-99) and roster
|
||||||
|
* cloning between seasons (HU-53).
|
||||||
|
*/
|
||||||
|
export const clubService = {
|
||||||
|
/**
|
||||||
|
* Retrieves a club and its trajectory across seasons.
|
||||||
|
* @param {string} idOrSlug - The club's GUID id or its slug.
|
||||||
|
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
|
||||||
|
*/
|
||||||
|
getClubHistory: async (
|
||||||
|
idOrSlug: string
|
||||||
|
): Promise<AxiosResponse<IClubHistoryResponse>> =>
|
||||||
|
await sendGet(`${routes.clubs}/${idOrSlug}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a roster from a previous season's team into the target team.
|
||||||
|
* @param {GUID} targetTeamId - The team to copy the roster into.
|
||||||
|
* @param {IRosterCopyRequest} request - The source team + season and target season.
|
||||||
|
* @returns {Promise<AxiosResponse<IRosterCopyResult>>} The server response.
|
||||||
|
*/
|
||||||
|
copyRoster: async (
|
||||||
|
targetTeamId: GUID,
|
||||||
|
request: IRosterCopyRequest
|
||||||
|
): Promise<AxiosResponse<IRosterCopyResult>> =>
|
||||||
|
await sendPost(`${routes.teams}/${targetTeamId}/roster/copy`, request),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves every club's stable identity summary.
|
||||||
|
* @returns {Promise<AxiosResponse<IClubSummaryResponse[]>>} The server response.
|
||||||
|
*/
|
||||||
|
getAllClubs: async (): Promise<AxiosResponse<IClubSummaryResponse[]>> =>
|
||||||
|
await sendGet(routes.clubs),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links a club as a squad of a parent institution club.
|
||||||
|
* @param {GUID} childClubId - The squad club to link.
|
||||||
|
* @param {GUID} parentClubId - The institution club it becomes a squad of.
|
||||||
|
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
|
||||||
|
*/
|
||||||
|
linkClubParent: async (
|
||||||
|
childClubId: GUID,
|
||||||
|
parentClubId: GUID
|
||||||
|
): Promise<AxiosResponse<IClubHistoryResponse>> =>
|
||||||
|
await sendPut(`${routes.clubs}/${childClubId}/parent`, { parentClubId }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a club's parent institution link, if any.
|
||||||
|
* @param {GUID} childClubId - The club to unlink.
|
||||||
|
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
|
||||||
|
*/
|
||||||
|
unlinkClubParent: async (
|
||||||
|
childClubId: GUID
|
||||||
|
): Promise<AxiosResponse<IClubHistoryResponse>> =>
|
||||||
|
await sendDelete(`${routes.clubs}/${childClubId}/parent`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a club.
|
||||||
|
* @param {GUID} clubId - The club to rename.
|
||||||
|
* @param {string} name - The new display name.
|
||||||
|
* @returns {Promise<AxiosResponse<IClubHistoryResponse>>} The server response.
|
||||||
|
*/
|
||||||
|
renameClub: async (
|
||||||
|
clubId: GUID,
|
||||||
|
name: string
|
||||||
|
): Promise<AxiosResponse<IClubHistoryResponse>> =>
|
||||||
|
await sendPut(`${routes.clubs}/${clubId}`, { name }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a club.
|
||||||
|
* @param {GUID} clubId - The club to delete.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} The server response.
|
||||||
|
*/
|
||||||
|
deleteClub: async (clubId: GUID): Promise<AxiosResponse<void>> =>
|
||||||
|
await sendDelete(`${routes.clubs}/${clubId}`),
|
||||||
|
};
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One season (tournament) a club's team was registered in.
|
||||||
|
* @interface IClubSeasonResponse
|
||||||
|
*/
|
||||||
|
export interface IClubSeasonResponse {
|
||||||
|
/** The tournament (season) id. */
|
||||||
|
tournamentId: GUID;
|
||||||
|
/** The tournament (season) display name, when available. */
|
||||||
|
tournamentName: string | null;
|
||||||
|
/**
|
||||||
|
* The tournament's start date (ISO string). Sort key only — the history
|
||||||
|
* table shows the tournament name, and rows are ordered newest-first by
|
||||||
|
* this value.
|
||||||
|
*/
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One per-season team belonging to a club, with the seasons it played.
|
||||||
|
* @interface IClubTeamSeasonResponse
|
||||||
|
*/
|
||||||
|
export interface IClubTeamSeasonResponse {
|
||||||
|
/** The per-season team id. */
|
||||||
|
teamId: GUID;
|
||||||
|
/** The team name for that season. */
|
||||||
|
name: string;
|
||||||
|
/** The team's URL-friendly slug. */
|
||||||
|
slug: string;
|
||||||
|
/** The three-letter code of the team. */
|
||||||
|
threeLetterCode: string;
|
||||||
|
/** The tournaments (seasons) this team was registered in. */
|
||||||
|
seasons: IClubSeasonResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal club identity, used for pickers and for referencing a related
|
||||||
|
* club (parent institution / child squad) without its full season history.
|
||||||
|
* @interface IClubSummaryResponse
|
||||||
|
*/
|
||||||
|
export interface IClubSummaryResponse {
|
||||||
|
id: GUID;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club and its trajectory across seasons (HU-99): the stable club identity
|
||||||
|
* plus every per-season team that belongs to it.
|
||||||
|
* @interface IClubHistoryResponse
|
||||||
|
*/
|
||||||
|
export interface IClubHistoryResponse {
|
||||||
|
/** The stable club id. */
|
||||||
|
id: GUID;
|
||||||
|
/** The club name. */
|
||||||
|
name: string;
|
||||||
|
/** The club's URL-friendly slug. */
|
||||||
|
slug: string;
|
||||||
|
/** The club logo URL, when available. */
|
||||||
|
logoUrl: string | null;
|
||||||
|
/** The per-season teams that make up this club's history. */
|
||||||
|
teams: IClubTeamSeasonResponse[];
|
||||||
|
/**
|
||||||
|
* The parent institution this club is a squad of, or null when this club
|
||||||
|
* has no parent linked.
|
||||||
|
*/
|
||||||
|
parentClub: IClubSummaryResponse | null;
|
||||||
|
/** Other squads linked to this club as their parent institution. */
|
||||||
|
childClubs: IClubSummaryResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body to clone a roster from a previous season's team (HU-53). The
|
||||||
|
* target team is taken from the route; this identifies the source team +
|
||||||
|
* season to copy from and the target season to copy into.
|
||||||
|
* @interface IRosterCopyRequest
|
||||||
|
*/
|
||||||
|
export interface IRosterCopyRequest {
|
||||||
|
/** The past-season team whose roster is the source. */
|
||||||
|
sourceTeamId: GUID;
|
||||||
|
/** The season (tournament) the source roster belongs to. */
|
||||||
|
sourceTournamentId: GUID;
|
||||||
|
/** The new season (tournament) the roster is cloned into. */
|
||||||
|
targetTournamentId: GUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of copying a roster into a new season (HU-53).
|
||||||
|
* @interface IRosterCopyResult
|
||||||
|
*/
|
||||||
|
export interface IRosterCopyResult {
|
||||||
|
/** New season registrations created on the target team. */
|
||||||
|
copiedCount: number;
|
||||||
|
/** Source players skipped because already registered to the target season. */
|
||||||
|
skippedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context properties for reading a club's cross-season history (HU-99) and
|
||||||
|
* importing a roster from a previous season (HU-53).
|
||||||
|
* @interface IClubContextProps
|
||||||
|
*/
|
||||||
|
export interface IClubContextProps {
|
||||||
|
/** The last-fetched club history, or null. */
|
||||||
|
club: IClubHistoryResponse | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a club and its per-season trajectory by id or slug.
|
||||||
|
* @param idOrSlug The club's GUID id or its slug.
|
||||||
|
* @returns A promise that resolves with the club history.
|
||||||
|
*/
|
||||||
|
getClubHistory(idOrSlug: string): Promise<IClubHistoryResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a roster from a previous season's team into a target team.
|
||||||
|
* @param targetTeamId The team to copy the roster into.
|
||||||
|
* @param request The source team + season and the target season.
|
||||||
|
* @returns A promise that resolves with the copied/skipped counts.
|
||||||
|
*/
|
||||||
|
copyRoster(
|
||||||
|
targetTeamId: GUID,
|
||||||
|
request: IRosterCopyRequest
|
||||||
|
): Promise<IRosterCopyResult | void>;
|
||||||
|
|
||||||
|
/** Every club's stable identity summary, for the "link to parent club" picker. */
|
||||||
|
allClubs: IClubSummaryResponse[];
|
||||||
|
|
||||||
|
/** Fetches every club's stable identity summary. */
|
||||||
|
getAllClubs(): Promise<IClubSummaryResponse[] | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links a club as a squad of a parent institution club.
|
||||||
|
* @param childClubId The squad club to link.
|
||||||
|
* @param parentClubId The institution club it becomes a squad of.
|
||||||
|
*/
|
||||||
|
linkClubParent(
|
||||||
|
childClubId: GUID,
|
||||||
|
parentClubId: GUID
|
||||||
|
): Promise<IClubHistoryResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a club's parent institution link, if any.
|
||||||
|
* @param childClubId The club to unlink.
|
||||||
|
*/
|
||||||
|
unlinkClubParent(childClubId: GUID): Promise<IClubHistoryResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a club. The club's slug never changes, so its public URL stays
|
||||||
|
* stable.
|
||||||
|
* @param clubId The club to rename.
|
||||||
|
* @param name The new display name.
|
||||||
|
*/
|
||||||
|
renameClub(clubId: GUID, name: string): Promise<IClubHistoryResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a club, rejected while it still has teams or squad clubs linked to it.
|
||||||
|
* @param clubId The club to delete.
|
||||||
|
*/
|
||||||
|
deleteClub(clubId: GUID): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for every frontend page path. Static paths are
|
||||||
|
* plain strings (used directly in Route definitions, nav links and
|
||||||
|
* navigate() calls). Dynamic paths expose both a `pattern` (with the
|
||||||
|
* :param placeholder, for Route definitions) and a `build` function (for
|
||||||
|
* navigate() calls with a real id).
|
||||||
|
*/
|
||||||
|
export const APP_ROUTES = {
|
||||||
|
home: '/',
|
||||||
|
login: '/login',
|
||||||
|
forgotPassword: '/auth/olvide-password',
|
||||||
|
activate: '/auth/activar',
|
||||||
|
passwordReset: '/auth/password-reset',
|
||||||
|
quienesSomos: '/quienes-somos',
|
||||||
|
fichaMedica: '/ficha-medica',
|
||||||
|
reglamento: '/reglamento',
|
||||||
|
forbidden: '/forbidden',
|
||||||
|
|
||||||
|
publicTeam: {
|
||||||
|
pattern: '/equipos/:teamId',
|
||||||
|
build: (teamId: string) => `/equipos/${teamId}`,
|
||||||
|
},
|
||||||
|
publicSanctions: '/sanciones',
|
||||||
|
publicChampions: '/campeones',
|
||||||
|
publicMatch: {
|
||||||
|
pattern: '/partidos/:matchId',
|
||||||
|
build: (matchId: string) => `/partidos/${matchId}`,
|
||||||
|
},
|
||||||
|
publicSeasons: '/temporadas',
|
||||||
|
publicSeason: {
|
||||||
|
pattern: '/temporadas/:seasonId',
|
||||||
|
build: (seasonId: string) => `/temporadas/${seasonId}`,
|
||||||
|
},
|
||||||
|
publicTournament: {
|
||||||
|
pattern: '/torneos/:tournamentId',
|
||||||
|
build: (tournamentId: string) => `/torneos/${tournamentId}`,
|
||||||
|
},
|
||||||
|
publicBlog: '/blog',
|
||||||
|
blogPost: {
|
||||||
|
pattern: '/blog/:idOrSlug',
|
||||||
|
build: (idOrSlug: string) => `/blog/${idOrSlug}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
panel: '/panel',
|
||||||
|
panelPlayers: '/panel/jugadores',
|
||||||
|
panelPlayer: {
|
||||||
|
pattern: '/panel/jugadores/:playerId',
|
||||||
|
build: (playerId: string) => `/panel/jugadores/${playerId}`,
|
||||||
|
},
|
||||||
|
panelTeamDetail: {
|
||||||
|
pattern: '/panel/equipos/:teamId',
|
||||||
|
build: (teamId: string) => `/panel/equipos/${teamId}`,
|
||||||
|
},
|
||||||
|
panelTournamentDetail: {
|
||||||
|
pattern: '/panel/torneos/:tournamentId',
|
||||||
|
build: (tournamentId: string) => `/panel/torneos/${tournamentId}`,
|
||||||
|
},
|
||||||
|
panelTournamentEdit: {
|
||||||
|
pattern: '/panel/torneos/:tournamentId/editar',
|
||||||
|
build: (tournamentId: string) => `/panel/torneos/${tournamentId}/editar`,
|
||||||
|
},
|
||||||
|
panelTeams: '/panel/equipos',
|
||||||
|
panelClub: {
|
||||||
|
pattern: '/panel/clubes/:idOrSlug',
|
||||||
|
build: (idOrSlug: string) => `/panel/clubes/${idOrSlug}`,
|
||||||
|
},
|
||||||
|
panelSanctions: '/panel/sanciones',
|
||||||
|
panelSanction: {
|
||||||
|
pattern: '/panel/sanciones/:playerSanctionId',
|
||||||
|
build: (playerSanctionId: string) => `/panel/sanciones/${playerSanctionId}`,
|
||||||
|
},
|
||||||
|
panelSanctionEdit: {
|
||||||
|
pattern: '/panel/sanciones/editar/:playerSanctionId',
|
||||||
|
build: (playerSanctionId: string) =>
|
||||||
|
`/panel/sanciones/editar/${playerSanctionId}`,
|
||||||
|
},
|
||||||
|
panelVenues: '/panel/canchas',
|
||||||
|
panelVenue: {
|
||||||
|
pattern: '/panel/canchas/:venueId',
|
||||||
|
build: (venueId: string) => `/panel/canchas/${venueId}`,
|
||||||
|
},
|
||||||
|
panelSeasons: '/panel/temporadas',
|
||||||
|
panelSeason: {
|
||||||
|
pattern: '/panel/temporadas/:seasonId',
|
||||||
|
build: (seasonId: string) => `/panel/temporadas/${seasonId}`,
|
||||||
|
},
|
||||||
|
panelTournamentWizard: '/panel/torneos/asistente',
|
||||||
|
panelDivisionCreate: '/panel/divisiones/crear',
|
||||||
|
panelDivisionEdit: {
|
||||||
|
pattern: '/panel/divisiones/:divisionId/editar',
|
||||||
|
build: (divisionId: string) => `/panel/divisiones/${divisionId}/editar`,
|
||||||
|
},
|
||||||
|
panelDivision: {
|
||||||
|
pattern: '/panel/divisiones/:divisionId',
|
||||||
|
build: (divisionId: string) => `/panel/divisiones/${divisionId}`,
|
||||||
|
},
|
||||||
|
panelMatch: {
|
||||||
|
pattern: '/panel/partidos/:matchId',
|
||||||
|
build: (matchId: string) => `/panel/partidos/${matchId}`,
|
||||||
|
},
|
||||||
|
panelBlog: '/panel/blog',
|
||||||
|
panelBlogCreate: '/panel/blog/crear',
|
||||||
|
panelBlogEdit: {
|
||||||
|
pattern: '/panel/blog/:blogPostId/editar',
|
||||||
|
build: (blogPostId: string) => `/panel/blog/${blogPostId}/editar`,
|
||||||
|
},
|
||||||
|
panelUsers: '/panel/usuarios',
|
||||||
|
panelUserCreate: '/panel/usuarios/crear',
|
||||||
|
panelUserInvite: '/panel/usuarios/invitar',
|
||||||
|
panelUserEdit: {
|
||||||
|
pattern: '/panel/usuarios/:userId/editar',
|
||||||
|
build: (userId: string) => `/panel/usuarios/${userId}/editar`,
|
||||||
|
},
|
||||||
|
panelUser: {
|
||||||
|
pattern: '/panel/usuarios/:userId',
|
||||||
|
build: (userId: string) => `/panel/usuarios/${userId}`,
|
||||||
|
},
|
||||||
|
panelSettings: '/panel/configuracion',
|
||||||
|
panelChangePassword: '/panel/configuracion/cambiar-password',
|
||||||
|
panelEditProfile: '/panel/configuracion/editar-perfil',
|
||||||
|
panelStatistics: '/panel/estadisticas',
|
||||||
|
panelAuditLogs: '/panel/auditoria',
|
||||||
|
panelDataAdministration: '/panel/administracion-datos',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const COOKIE_SIGNIN_TOKEN = 'Club12_SignInToken';
|
||||||
|
|
||||||
|
export const SUCCESS_MESSAGES = {
|
||||||
|
LOGIN_SUCCESS: 'Sesión iniciada correctamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
GENERIC_ERROR: 'Ocurrió un error. Por favor, intentá nuevamente.',
|
||||||
|
NETWORK_ERROR:
|
||||||
|
'No se pudo conectar con el servidor. Verificá tu conexión e intentá nuevamente.',
|
||||||
|
SERVER_UNAVAILABLE:
|
||||||
|
'El servidor no está disponible en este momento. Por favor, intentá nuevamente en unos minutos.',
|
||||||
|
LOGIN_FAILED: 'Usuario o contraseña incorrectos',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXPIRATION_TIME = {
|
||||||
|
MS_IN_HOUR: 3600 * 1000,
|
||||||
|
MS_IN_MINUTE: 60 * 1000,
|
||||||
|
MS_IN_SECOND: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JWT = {
|
||||||
|
ACCESS_TOKEN: 'accessToken',
|
||||||
|
REFRESH_TOKEN: 'refreshToken',
|
||||||
|
EXPIRES_IN: 'expiresIn',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FILTERS_DEBOUNCE_DELAY_MS = 500;
|
||||||
|
export const FILTERS_DEBOUNCE_DELAY_LONG_MS = 1000;
|
||||||
|
export const PUBLIC_SEARCH_DEBOUNCE_DELAY_MS = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied to tabbed content areas (tournament/division tabs) so switching
|
||||||
|
* between a short tab (e.g. Información) and a long one (e.g. Partidos)
|
||||||
|
* doesn't visibly jump the page height and the footer position with it.
|
||||||
|
*/
|
||||||
|
export const TAB_CONTENT_MIN_HEIGHT = 400;
|
||||||
|
|
||||||
|
export const USERNAME_LENGTH = {
|
||||||
|
Min: 3,
|
||||||
|
Max: 50,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { esES } from '@mui/x-data-grid/locales';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spanish (es-ES) localeText for the MUI X DataGrid — pager ("Filas por
|
||||||
|
* página", "de"), filters, column menu, etc.
|
||||||
|
*
|
||||||
|
* The theme already merges `esES` into
|
||||||
|
* `theme.components.MuiDataGrid.defaultProps.localeText`, which covers grids
|
||||||
|
* that do NOT pass their own `localeText`. But MUI's `resolveProps` only fills
|
||||||
|
* a prop from `defaultProps` when the component leaves it `undefined`: a page
|
||||||
|
* that passes `localeText={{ noRowsLabel }}` shallow-REPLACES the theme's
|
||||||
|
* Spanish localeText, so the footer falls back to the English defaults.
|
||||||
|
*
|
||||||
|
* Any DataGrid that needs a custom empty-rows message must therefore spread
|
||||||
|
* this constant into its `localeText` prop. `dataGridLocaleText(noRowsLabel)`
|
||||||
|
* does exactly that in a single call.
|
||||||
|
*/
|
||||||
|
export const DATA_GRID_ES_LOCALE_TEXT =
|
||||||
|
esES.components.MuiDataGrid.defaultProps.localeText;
|
||||||
|
|
||||||
|
/** Spanish DataGrid localeText merged with a page-specific empty-rows label. */
|
||||||
|
export const dataGridLocaleText = (
|
||||||
|
noRowsLabel: string
|
||||||
|
): typeof DATA_GRID_ES_LOCALE_TEXT => ({
|
||||||
|
...DATA_GRID_ES_LOCALE_TEXT,
|
||||||
|
noRowsLabel,
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Named HTTP status codes used to interpret API responses, instead of
|
||||||
|
* comparing against raw numeric literals throughout the app.
|
||||||
|
*/
|
||||||
|
export const HttpStatus = {
|
||||||
|
Ok: 200,
|
||||||
|
Created: 201,
|
||||||
|
NoContent: 204,
|
||||||
|
BadRequest: 400,
|
||||||
|
Unauthorized: 401,
|
||||||
|
Forbidden: 403,
|
||||||
|
NotFound: 404,
|
||||||
|
Conflict: 409,
|
||||||
|
InternalServerError: 500,
|
||||||
|
BadGateway: 502,
|
||||||
|
ServiceUnavailable: 503,
|
||||||
|
GatewayTimeout: 504,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum Order {
|
||||||
|
ASC = 0,
|
||||||
|
DESC = 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Filtered } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
export const TABLE_ROWS_PER_PAGE = 10;
|
||||||
|
export const TABLE_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page size used when fetching the full list of tournaments/divisions to
|
||||||
|
* populate a filter dropdown, effectively treating the fetch as "get all".
|
||||||
|
*/
|
||||||
|
export const FILTER_OPTIONS_PAGE_SIZE = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page size used on public (unauthenticated) listing pages that fetch
|
||||||
|
* effectively all items in a single request.
|
||||||
|
*/
|
||||||
|
export const PUBLIC_LISTING_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export const withTablePageSize = <T extends Filtered>(
|
||||||
|
filter: T
|
||||||
|
): T & { pageSize: number } => ({
|
||||||
|
...filter,
|
||||||
|
pageSize: filter.pageSize ?? TABLE_ROWS_PER_PAGE,
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import routes from './routes';
|
||||||
|
|
||||||
|
describe('routes', () => {
|
||||||
|
it('tokenInvalido resolves to the invalid-token redirect path', () => {
|
||||||
|
expect(routes.tokenInvalido).toBe('/token-invalido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apiUrl is the relative same-origin path /api (no hardcoded host)', () => {
|
||||||
|
expect(routes.apiUrl).toBe('/api');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const routes = {
|
||||||
|
apiUrl: '/api',
|
||||||
|
|
||||||
|
auditLogs: 'audit-logs',
|
||||||
|
backups: 'backups',
|
||||||
|
clubs: 'clubs',
|
||||||
|
blogposts: 'blogposts',
|
||||||
|
champions: 'champions',
|
||||||
|
maintenance: 'maintenance',
|
||||||
|
dataMaintenance: 'data-maintenance',
|
||||||
|
divisions: 'divisions',
|
||||||
|
stages: 'stages',
|
||||||
|
matches: 'matches',
|
||||||
|
matchSeries: 'match-series',
|
||||||
|
medicalRecords: 'medical-records',
|
||||||
|
players: 'players',
|
||||||
|
seasons: 'seasons',
|
||||||
|
scorer: 'scorer',
|
||||||
|
playerSanctions: 'player-sanctions',
|
||||||
|
pointDeductions: 'point-deductions',
|
||||||
|
playerStatistics: 'player-statistics',
|
||||||
|
statistics: 'statistics',
|
||||||
|
staff: 'staff',
|
||||||
|
teams: 'teams',
|
||||||
|
tournaments: 'tournaments',
|
||||||
|
users: 'users',
|
||||||
|
auth: 'auth',
|
||||||
|
venues: 'venues',
|
||||||
|
tokenInvalido: '/token-invalido',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* The lifecycle status of a match (HU-69/HU-73). Mirrors the backend
|
||||||
|
* `Domain.Enums.MatchStatus` and is serialized as a string on the match
|
||||||
|
* response DTOs.
|
||||||
|
* @enum MatchStatus
|
||||||
|
*/
|
||||||
|
export enum MatchStatus {
|
||||||
|
/**
|
||||||
|
* The match has a fixture but no result loaded yet.
|
||||||
|
*/
|
||||||
|
Scheduled = 'Scheduled',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A normal result was loaded (HU-69); the match has a winner.
|
||||||
|
*/
|
||||||
|
Played = 'Played',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The match was suspended (HU-68/HU-73) and awaits rescheduling.
|
||||||
|
*/
|
||||||
|
Suspended = 'Suspended',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A walkover was applied (HU-73): the present team was awarded the
|
||||||
|
* regulation default result. Distinguishable from a normal `Played`.
|
||||||
|
*/
|
||||||
|
WalkOver = 'WalkOver',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The match will never be played: its tournament was canceled, or
|
||||||
|
* force-closed as finished while the match was still pending.
|
||||||
|
*/
|
||||||
|
Canceled = 'Canceled',
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* The types of matches that can exist (Regular or Playoff).
|
||||||
|
* @enum MatchType
|
||||||
|
*/
|
||||||
|
export enum MatchType {
|
||||||
|
/**
|
||||||
|
* A regular match in the tournament.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
Regular = 'Regular',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A playoff match in the tournament.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
Playoff = 'Playoff',
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* The medical-record / eligibility status of a player's season registration
|
||||||
|
* (HU-57). Mirrors the backend `Domain.Enums.MedicalRecordStatus` and is
|
||||||
|
* serialized as a string on the medical-record and roster response DTOs.
|
||||||
|
*
|
||||||
|
* The status is scoped per player + team + tournament, so being `Approved`
|
||||||
|
* in one season never carries over to another (HU-59). A player is
|
||||||
|
* "habilitado" only when the record is {@link Approved}.
|
||||||
|
* @enum MedicalRecordStatus
|
||||||
|
*/
|
||||||
|
export enum MedicalRecordStatus {
|
||||||
|
/**
|
||||||
|
* No medical record uploaded yet, or uploaded but not reviewed. The player
|
||||||
|
* is NOT habilitado.
|
||||||
|
*/
|
||||||
|
Pending = 'Pending',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner/admin reviewed and approved the record (HU-58): the player is
|
||||||
|
* habilitado for that team and tournament (HU-57).
|
||||||
|
*/
|
||||||
|
Approved = 'Approved',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner/admin rejected the record (HU-58), usually with a reason. The
|
||||||
|
* player is NOT habilitado.
|
||||||
|
*/
|
||||||
|
Rejected = 'Rejected',
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Competitive category (gender) of a tournament (HU-48). The feminine
|
||||||
|
* competition is, by club rule, a SEPARATE tournament: a single tournament can
|
||||||
|
* never mix feminine and masculine divisions. The category lives on the
|
||||||
|
* tournament and every one of its divisions must share it.
|
||||||
|
*
|
||||||
|
* Values mirror the backend `Domain.Enums.TournamentCategory` names exactly
|
||||||
|
* (the API serializes enums as strings via JsonStringEnumConverter).
|
||||||
|
*/
|
||||||
|
export const TournamentCategory = {
|
||||||
|
Masculine: 'Masculine',
|
||||||
|
Feminine: 'Feminine',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentCategory =
|
||||||
|
(typeof TournamentCategory)[keyof typeof TournamentCategory];
|
||||||
|
|
||||||
|
/** Spanish display labels for each category, shared by the wizard and views. */
|
||||||
|
export const TOURNAMENT_CATEGORY_LABELS: Record<TournamentCategory, string> = {
|
||||||
|
[TournamentCategory.Masculine]: 'Masculino',
|
||||||
|
[TournamentCategory.Feminine]: 'Femenino',
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const TournamentStatus = {
|
||||||
|
Scheduled: 'Scheduled',
|
||||||
|
OpenForRegistration: 'OpenForRegistration',
|
||||||
|
RegistrationClosed: 'RegistrationClosed',
|
||||||
|
Ongoing: 'Ongoing',
|
||||||
|
Finished: 'Finished',
|
||||||
|
Canceled: 'Canceled',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentStatus =
|
||||||
|
(typeof TournamentStatus)[keyof typeof TournamentStatus];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const UserRolesType = {
|
||||||
|
Admin: 'ADMIN',
|
||||||
|
Owner: 'OWNER',
|
||||||
|
Guest: 'GUEST',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRolesType = (typeof UserRolesType)[keyof typeof UserRolesType];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical Spanish display labels for every role. Co-located with the enum
|
||||||
|
* so every view (users list, create-user form, edit-user form) shows the
|
||||||
|
* same wording instead of each screen inventing its own.
|
||||||
|
*
|
||||||
|
* HU-05: the role model is reduced to Owner and Admin (plus the technical
|
||||||
|
* Guest). `Tournament Manager` and `Team Manager` were removed from the
|
||||||
|
* system and the UI.
|
||||||
|
*/
|
||||||
|
export const USER_ROLE_LABELS: Record<UserRolesType, string> = {
|
||||||
|
[UserRolesType.Admin]: 'Admin',
|
||||||
|
[UserRolesType.Owner]: 'Owner',
|
||||||
|
[UserRolesType.Guest]: 'Invitado',
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Order } from '@/modules/core/constants/order';
|
||||||
|
export interface ProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericResponsePagination<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filtered {
|
||||||
|
pageNumber?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: Order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-call options for a context GET method. `silent` suppresses the global
|
||||||
|
* blocking alert on failure so a public page can render a quiet inline retry
|
||||||
|
* state instead — mutations (save/delete) never pass it and keep their alerts.
|
||||||
|
* `force` skips the context's "already have it in the local list" cache hit
|
||||||
|
* and re-fetches from the server — needed right after a mutation that
|
||||||
|
* changes data nested inside the cached item (e.g. deleting a tournament
|
||||||
|
* from within a season), which the cache hit would otherwise mask.
|
||||||
|
*/
|
||||||
|
export interface FetchOptions {
|
||||||
|
silent?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestProps = {
|
||||||
|
method: string;
|
||||||
|
resource: string;
|
||||||
|
configOverride?: object;
|
||||||
|
body?: unknown;
|
||||||
|
query?: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GUID = `${string}-${string}-${string}-${string}-${string}`;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { sendDelete, sendGet, sendPost } from './axiosUtils';
|
||||||
|
import { getActiveRequestCount } from './requestActivity';
|
||||||
|
|
||||||
|
const originalLocation = window.location;
|
||||||
|
|
||||||
|
const mockAssign = (): ReturnType<typeof vi.fn> => {
|
||||||
|
const assignSpy = vi.fn();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: { ...originalLocation, assign: assignSpy },
|
||||||
|
});
|
||||||
|
return assignSpy;
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('axios', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('axios')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: {
|
||||||
|
...actual.default,
|
||||||
|
request: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildUnauthorizedError = (hasAuthHeader: boolean): AxiosError =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: 'Request failed with status code 401',
|
||||||
|
config: {
|
||||||
|
headers: hasAuthHeader ? { Authorization: 'Bearer expired-token' } : {},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
status: 401,
|
||||||
|
data: {},
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: {} },
|
||||||
|
},
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as unknown as AxiosError;
|
||||||
|
|
||||||
|
describe('axiosUtils invalid-token redirect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /token-invalido when a 401 error carries an Authorization header', async () => {
|
||||||
|
const assignSpy = mockAssign();
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(
|
||||||
|
buildUnauthorizedError(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sendDelete('divisions/123')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(assignSpy).toHaveBeenCalledWith('/token-invalido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT redirect when a 401 error carries no Authorization header (and is not a refresh-token request)', async () => {
|
||||||
|
const assignSpy = mockAssign();
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(
|
||||||
|
buildUnauthorizedError(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sendDelete('divisions/123')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildNotFoundError = (): AxiosError =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: 'Request failed with status code 404',
|
||||||
|
config: { headers: {} },
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: {
|
||||||
|
title: 'Not Found: The specified resource could not be found.',
|
||||||
|
detail: 'Division with id 123 not found.',
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
statusText: 'Not Found',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: {} },
|
||||||
|
},
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as unknown as AxiosError;
|
||||||
|
|
||||||
|
describe('sendGet error pipeline', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /token-invalido when a GET request gets a 401 with an Authorization header', async () => {
|
||||||
|
const assignSpy = mockAssign();
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(
|
||||||
|
buildUnauthorizedError(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sendGet('divisions/123')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(assignSpy).toHaveBeenCalledWith('/token-invalido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with the same error shape as other verbs when a GET request gets a 404', async () => {
|
||||||
|
const notFoundError = buildNotFoundError();
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(notFoundError);
|
||||||
|
|
||||||
|
await expect(sendGet('divisions/123')).rejects.toBe(notFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mutating-request activity tracking (drives GlobalLoadingOverlay)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments the active count while a POST is in flight, then releases it on success', async () => {
|
||||||
|
let resolveRequest: (value: unknown) => void = () => {};
|
||||||
|
const pending = new Promise(resolve => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(axios.request).mockReturnValueOnce(pending as never);
|
||||||
|
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
const requestPromise = sendPost('teams', { name: 'River' });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(getActiveRequestCount()).toBe(1);
|
||||||
|
|
||||||
|
resolveRequest({ data: {}, status: 201, statusText: 'Created', headers: {}, config: {} });
|
||||||
|
await requestPromise;
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the active count even when the mutating request fails', async () => {
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(buildNotFoundError());
|
||||||
|
|
||||||
|
await expect(sendPost('teams', { name: 'River' })).rejects.toBeTruthy();
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT affect the active count for a GET (page data keeps its own skeleton loading)', async () => {
|
||||||
|
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendGet('teams');
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import axios, { AxiosError, AxiosResponse } from 'axios';
|
||||||
|
import jsCookie from 'js-cookie';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import {
|
||||||
|
ERROR_MESSAGES,
|
||||||
|
COOKIE_SIGNIN_TOKEN,
|
||||||
|
JWT,
|
||||||
|
} from '@/modules/core/constants/constants';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
import { beginRequest, endRequest } from '@/modules/core/utils/requestActivity';
|
||||||
|
|
||||||
|
const TOKEN_KEY: string = COOKIE_SIGNIN_TOKEN;
|
||||||
|
const INVALID_TOKEN_PATH = routes.tokenInvalido;
|
||||||
|
|
||||||
|
type headersContent = {
|
||||||
|
'Content-Type'?: string;
|
||||||
|
Authorization?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigOverride = {
|
||||||
|
headers?: headersContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCodeHandlers: Record<
|
||||||
|
number,
|
||||||
|
((response: AxiosResponse) => void)[]
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
const hasAuthorizationHeader = (error: AxiosError): boolean => {
|
||||||
|
const headers = error.config?.headers as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const authorizationHeader = headers?.Authorization ?? headers?.authorization;
|
||||||
|
return Boolean(authorizationHeader);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRefreshTokenRequest = (error: AxiosError): boolean => {
|
||||||
|
const requestUrl = error.config?.url ?? '';
|
||||||
|
return requestUrl.includes('/auth/refresh-token');
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectToInvalidToken = (): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname === INVALID_TOKEN_PATH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterToken();
|
||||||
|
localStorage.removeItem(JWT.REFRESH_TOKEN);
|
||||||
|
window.location.assign(INVALID_TOKEN_PATH);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerStatusCodeHandlers = (response: AxiosResponse): void => {
|
||||||
|
const handlers = statusCodeHandlers[response.status];
|
||||||
|
if (!handlers?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.forEach(callback => {
|
||||||
|
callback(response);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnauthorizedToken = (error: AxiosError): void => {
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
if (statusCode !== HttpStatus.Unauthorized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRedirect =
|
||||||
|
hasAuthorizationHeader(error) || isRefreshTokenRequest(error);
|
||||||
|
|
||||||
|
if (shouldRedirect) {
|
||||||
|
redirectToInvalidToken();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a token is set in cookies.
|
||||||
|
* @returns {boolean} True if the token exists, false otherwise.
|
||||||
|
*/
|
||||||
|
export const tokenIsSet = (): boolean => !!jsCookie.get(TOKEN_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a token in cookies.
|
||||||
|
* @param {string} newToken - The token to store.
|
||||||
|
* @param {Date} expirationDate - The expiration date for the token.
|
||||||
|
*/
|
||||||
|
export const registerToken = (newToken: string, expirationDate: Date) => {
|
||||||
|
jsCookie.set(TOKEN_KEY, newToken, {
|
||||||
|
expires: expirationDate,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters (removes) the token from cookies.
|
||||||
|
*/
|
||||||
|
export const unregisterToken = (): void => {
|
||||||
|
jsCookie.remove(TOKEN_KEY, {
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the currently registered token.
|
||||||
|
* @returns {string | undefined} The registered token, or undefined if none is set.
|
||||||
|
*/
|
||||||
|
export const getRegisteredToken = (): string | undefined =>
|
||||||
|
jsCookie.get(TOKEN_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the default headers for requests.
|
||||||
|
* @returns {headersContent} The default headers.
|
||||||
|
*/
|
||||||
|
const getDefaultHeaders = (): headersContent => {
|
||||||
|
const headers: headersContent = {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokenIsSet()) {
|
||||||
|
const token = jsCookie.get(TOKEN_KEY);
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges custom headers with default headers.
|
||||||
|
* @param {ConfigOverride} [configOverride] - The override configuration.
|
||||||
|
* @returns {headersContent} The resulting headers.
|
||||||
|
*/
|
||||||
|
const getHeaders = (configOverride?: ConfigOverride): headersContent => {
|
||||||
|
let headers: headersContent = getDefaultHeaders();
|
||||||
|
|
||||||
|
if (configOverride?.headers) {
|
||||||
|
headers = {
|
||||||
|
...headers,
|
||||||
|
...configOverride.headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full API endpoint URL, including optional query parameters.
|
||||||
|
* @param {string} resource - The API resource.
|
||||||
|
* @param {object} [query] - The query parameters as an object.
|
||||||
|
* @returns {string} The encoded full endpoint URL.
|
||||||
|
*/
|
||||||
|
export const buildEndpoint = (resource: string, query?: object): string => {
|
||||||
|
const finalResource = `${routes.apiUrl}/${resource}`;
|
||||||
|
if (query) {
|
||||||
|
const queryParams = Object.entries(query)
|
||||||
|
.filter(
|
||||||
|
([, value]) => value !== undefined && value !== null && value !== ''
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
|
||||||
|
)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
if (queryParams.length > 0) {
|
||||||
|
return `${finalResource}?${queryParams}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalResource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTTP request.
|
||||||
|
* @param {string} method - HTTP method (GET, POST, PUT, DELETE).
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {object} [configOverride] - Request configuration overrides.
|
||||||
|
* @param {unknown | null} body - Request body data.
|
||||||
|
* @param {object} [query] - Query parameters.
|
||||||
|
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the response or undefined.
|
||||||
|
*/
|
||||||
|
const sendRequest = async <T>(
|
||||||
|
method: string,
|
||||||
|
resource: string,
|
||||||
|
configOverride: object = {},
|
||||||
|
body: unknown | null = null,
|
||||||
|
query?: object
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
const headers = getHeaders(configOverride);
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
delete headers['Content-Type'];
|
||||||
|
}
|
||||||
|
const url = buildEndpoint(resource, query);
|
||||||
|
|
||||||
|
// Mutations (loading/uploading/saving something) block the whole screen
|
||||||
|
// via GlobalLoadingOverlay; GETs keep their own skeleton-loading pattern.
|
||||||
|
const isMutation = method !== 'GET';
|
||||||
|
if (isMutation) {
|
||||||
|
beginRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: AxiosResponse<T> = await axios.request({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw throwError(error);
|
||||||
|
} finally {
|
||||||
|
if (isMutation) {
|
||||||
|
endRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an appropriate error based on its type.
|
||||||
|
* @param {unknown} error - The error object.
|
||||||
|
* @returns {AxiosError | Error} - The error object processed.
|
||||||
|
*/
|
||||||
|
const throwError = (error: unknown): AxiosError | Error => {
|
||||||
|
switch (true) {
|
||||||
|
case axios.isAxiosError(error): {
|
||||||
|
if (error.response) {
|
||||||
|
triggerStatusCodeHandlers(error.response);
|
||||||
|
}
|
||||||
|
handleUnauthorizedToken(error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
case error instanceof Error:
|
||||||
|
return new AxiosError(
|
||||||
|
error.message,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new AxiosError(ERROR_MESSAGES.GENERIC_ERROR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a POST HTTP request.
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {unknown} [body] - Request body.
|
||||||
|
* @param {ConfigOverride} [configOverride] - Configuration overrides.
|
||||||
|
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
export const sendPost = async <T>(
|
||||||
|
resource: string,
|
||||||
|
body?: unknown,
|
||||||
|
configOverride?: ConfigOverride
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
return await sendRequest<T>('POST', resource, configOverride, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a PUT HTTP request.
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {unknown} body - Request body.
|
||||||
|
* @param {ConfigOverride} [configOverride] - Configuration overrides.
|
||||||
|
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
export const sendPut = async <T>(
|
||||||
|
resource: string,
|
||||||
|
body: unknown,
|
||||||
|
configOverride?: ConfigOverride
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
return await sendRequest<T>('PUT', resource, configOverride, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a GET HTTP request.
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {object} [query] - Query parameters.
|
||||||
|
* @returns {Promise<AxiosResponse<T>>} A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
export const sendGet = async <T>(
|
||||||
|
resource: string,
|
||||||
|
query?: object
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
return await sendRequest<T>('GET', resource, {}, null, query);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a DELETE HTTP request.
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {ConfigOverride} [configOverride] - Configuration overrides.
|
||||||
|
* @returns {Promise<AxiosResponse<T>>} A promise that resolves when the resource is deleted.
|
||||||
|
*/
|
||||||
|
export const sendDelete = async <T>(
|
||||||
|
resource: string,
|
||||||
|
configOverride?: ConfigOverride,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<AxiosResponse<T>> =>
|
||||||
|
await sendRequest<T>('DELETE', resource, configOverride, body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a file from the server.
|
||||||
|
* @param {string} resource - API resource.
|
||||||
|
* @param {string} fileNameWithExtension - Name of the file to save locally.
|
||||||
|
*/
|
||||||
|
export const downloadfile = async (
|
||||||
|
resource: string,
|
||||||
|
fileNameWithExtension: string
|
||||||
|
) => {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const url = buildEndpoint(resource);
|
||||||
|
|
||||||
|
const result = await axios.get(url, { headers, responseType: 'blob' });
|
||||||
|
|
||||||
|
const fileUrl = window.URL.createObjectURL(new Blob([result.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.setAttribute('download', fileNameWithExtension);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback for a specific HTTP status code.
|
||||||
|
* @param {number} statusCode - HTTP status code.
|
||||||
|
* @param {() => unknown} callback - Function to execute when the status code is received.
|
||||||
|
*/
|
||||||
|
export const onStatusCode = (statusCode: number, callback: () => unknown) => {
|
||||||
|
if (statusCodeHandlers[statusCode]) {
|
||||||
|
statusCodeHandlers[statusCode].push(callback);
|
||||||
|
} else {
|
||||||
|
statusCodeHandlers[statusCode] = [callback];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback for the 401 Unauthorized status code.
|
||||||
|
* @param {() => unknown} callback - Function to execute when a 401 status code is received.
|
||||||
|
*/
|
||||||
|
export const onUnauthorized = (callback: () => unknown) => {
|
||||||
|
onStatusCode(HttpStatus.Unauthorized, callback);
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
interface ListItemWithId {
|
||||||
|
id: GUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper para realizar un fetch de una lista de la API y actualizar el estado condicionalmente.
|
||||||
|
* La llamada a la API siempre se realiza. El estado solo se actualiza si los datos han cambiado.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @template F
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {(filter: F) => Promise<AxiosResponse<GenericResponsePagination<T>>>} apiCall
|
||||||
|
* @param {T[] | null} currentState
|
||||||
|
* @param {React.Dispatch<React.SetStateAction<T[] | null>>} setState
|
||||||
|
* @param {F} filter
|
||||||
|
* @returns {Promise<GenericResponsePagination<T> | void>}
|
||||||
|
*/
|
||||||
|
export async function fetchAndSetList<T extends ListItemWithId, F>(options: {
|
||||||
|
apiCall: (filter: F) => Promise<AxiosResponse<GenericResponsePagination<T>>>;
|
||||||
|
currentState: T[] | null;
|
||||||
|
setState: React.Dispatch<React.SetStateAction<T[] | null>>;
|
||||||
|
filter: F;
|
||||||
|
}): Promise<GenericResponsePagination<T> | void> {
|
||||||
|
const { apiCall, currentState, setState, filter } = options;
|
||||||
|
|
||||||
|
const res: AxiosResponse<GenericResponsePagination<T>> =
|
||||||
|
await apiCall(filter);
|
||||||
|
|
||||||
|
if (res && res.data) {
|
||||||
|
const newItems = res.data.items;
|
||||||
|
|
||||||
|
const currentIds = (currentState || [])
|
||||||
|
.map(item => item.id)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
const newIds = newItems
|
||||||
|
.map(item => item.id)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
if (newIds !== currentIds) {
|
||||||
|
setState(newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { CANCEL_BUTTON_COLOR, getTheme } from '@/theme';
|
||||||
|
|
||||||
|
const theme = getTheme('dark');
|
||||||
|
const DIALOG_BACKGROUND = theme.palette.background.paper;
|
||||||
|
const DIALOG_TEXT_COLOR = theme.palette.text.primary;
|
||||||
|
|
||||||
|
// SweetAlert's container defaults to z-index 1060, which sits BELOW MUI's
|
||||||
|
// modals/dialogs (1300+). A confirm/notify fired while a MUI Dialog is open
|
||||||
|
// then renders behind it — its buttons unclickable and the awaited promise
|
||||||
|
// never resolving. Lifting the container above every MUI layer keeps alerts
|
||||||
|
// on top wherever they are triggered from.
|
||||||
|
const OVER_MUI_ZINDEX = '2000';
|
||||||
|
|
||||||
|
const liftAboveMuiModals = () => {
|
||||||
|
const container = Swal.getContainer();
|
||||||
|
if (container) {
|
||||||
|
container.style.zIndex = OVER_MUI_ZINDEX;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotifyIcon = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface NotifyOptions {
|
||||||
|
title: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmActionOptions {
|
||||||
|
title: string;
|
||||||
|
text?: string;
|
||||||
|
icon?: 'warning' | 'question';
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
confirmButtonColor?: string;
|
||||||
|
cancelButtonColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmDeleteOptions {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
confirmButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notify(icon: NotifyIcon, options: NotifyOptions): Promise<void> {
|
||||||
|
await Swal.fire({
|
||||||
|
title: options.title,
|
||||||
|
text: options.text,
|
||||||
|
icon,
|
||||||
|
confirmButtonColor: theme.palette.primary.main,
|
||||||
|
background: DIALOG_BACKGROUND,
|
||||||
|
color: DIALOG_TEXT_COLOR,
|
||||||
|
didOpen: liftAboveMuiModals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized success dialog using the app theme colors.
|
||||||
|
* @param options - Title and optional text.
|
||||||
|
*/
|
||||||
|
export const notifySuccess = (options: NotifyOptions): Promise<void> =>
|
||||||
|
notify('success', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized error dialog using the app theme colors.
|
||||||
|
* @param options - Title and optional text.
|
||||||
|
*/
|
||||||
|
export const notifyError = (options: NotifyOptions): Promise<void> =>
|
||||||
|
notify('error', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized warning dialog using the app theme colors.
|
||||||
|
* Intended for validation messages that do not require confirmation.
|
||||||
|
* @param options - Title and optional text.
|
||||||
|
*/
|
||||||
|
export const notifyWarning = (options: NotifyOptions): Promise<void> =>
|
||||||
|
notify('warning', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized info dialog using the app theme colors.
|
||||||
|
* @param options - Title and optional text.
|
||||||
|
*/
|
||||||
|
export const notifyInfo = (options: NotifyOptions): Promise<void> =>
|
||||||
|
notify('info', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized confirm/cancel dialog. Button colors default to the
|
||||||
|
* app theme but can be overridden for semantic actions (e.g. a destructive
|
||||||
|
* action in red, an activation action in green).
|
||||||
|
* @param options - Title, text, icon and optional button text/colors.
|
||||||
|
* @returns `true` if the user confirmed the action, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export async function confirmAction(options: ConfirmActionOptions): Promise<boolean> {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: options.title,
|
||||||
|
text: options.text,
|
||||||
|
icon: options.icon ?? 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: options.confirmButtonColor ?? theme.palette.primary.main,
|
||||||
|
cancelButtonColor: options.cancelButtonColor ?? CANCEL_BUTTON_COLOR,
|
||||||
|
confirmButtonText: options.confirmButtonText ?? 'Confirmar',
|
||||||
|
cancelButtonText: options.cancelButtonText ?? 'Cancelar',
|
||||||
|
background: DIALOG_BACKGROUND,
|
||||||
|
color: DIALOG_TEXT_COLOR,
|
||||||
|
didOpen: liftAboveMuiModals,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.isConfirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a standardized delete-confirmation dialog using the app theme colors.
|
||||||
|
* @param options - Title, text and optional confirm button text.
|
||||||
|
* @returns `true` if the user confirmed the action, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export const confirmDelete = (options: ConfirmDeleteOptions): Promise<boolean> =>
|
||||||
|
confirmAction({ ...options, confirmButtonText: options.confirmButtonText ?? 'Sí, eliminar' });
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildCsv, parseCsv } from '@/modules/core/utils/csv';
|
||||||
|
|
||||||
|
describe('buildCsv', () => {
|
||||||
|
it('emits the header row followed by data rows, CRLF-separated', () => {
|
||||||
|
const csv = buildCsv(
|
||||||
|
['#', 'Jugador', 'Puntos'],
|
||||||
|
[
|
||||||
|
[1, 'Ana Gómez', 12],
|
||||||
|
[2, 'Beto Ruiz', 9],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(csv).toBe(
|
||||||
|
'#,Jugador,Puntos\r\n1,Ana Gómez,12\r\n2,Beto Ruiz,9'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes cells containing commas, quotes or line breaks and doubles inner quotes', () => {
|
||||||
|
const csv = buildCsv(
|
||||||
|
['Equipo', 'Nota'],
|
||||||
|
[
|
||||||
|
['Club, 12', 'dijo "hola"'],
|
||||||
|
['Salto\nAlto', 'ok'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = csv.split('\r\n');
|
||||||
|
expect(lines[0]).toBe('Equipo,Nota');
|
||||||
|
expect(lines[1]).toBe('"Club, 12","dijo ""hola"""');
|
||||||
|
// A cell with a newline is wrapped in quotes, so the record spans two lines.
|
||||||
|
expect(csv).toContain('"Salto\nAlto",ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders null and undefined cells as empty strings', () => {
|
||||||
|
const csv = buildCsv(['A', 'B', 'C'], [[null, undefined, 0]]);
|
||||||
|
|
||||||
|
expect(csv).toBe('A,B,C\r\n,,0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCsv', () => {
|
||||||
|
it('splits the header from data rows and trims header whitespace', () => {
|
||||||
|
const parsed = parseCsv('Nombre,Apellido\r\nAna,Gómez\r\nBeto,Ruiz');
|
||||||
|
|
||||||
|
expect(parsed.headers).toEqual(['Nombre', 'Apellido']);
|
||||||
|
expect(parsed.rows).toEqual([
|
||||||
|
['Ana', 'Gómez'],
|
||||||
|
['Beto', 'Ruiz'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescapes quoted cells containing commas and doubled quotes', () => {
|
||||||
|
const parsed = parseCsv(
|
||||||
|
'Equipo,Nota\r\n"Club, 12","dijo ""hola"""'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.rows).toEqual([['Club, 12', 'dijo "hola"']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a leading UTF-8 BOM and skips blank lines', () => {
|
||||||
|
const parsed = parseCsv('A,B\r\n1,2\r\n\r\n3,4\r\n');
|
||||||
|
|
||||||
|
expect(parsed.headers).toEqual(['A', 'B']);
|
||||||
|
expect(parsed.rows).toEqual([
|
||||||
|
['1', '2'],
|
||||||
|
['3', '4'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty headers/rows for empty input', () => {
|
||||||
|
expect(parseCsv('')).toEqual({ headers: [], rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips what buildCsv produces', () => {
|
||||||
|
const csv = buildCsv(
|
||||||
|
['Nombre', 'Nota'],
|
||||||
|
[['Ana, con coma', 'dijo "hola"']]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseCsv(csv);
|
||||||
|
|
||||||
|
expect(parsed.headers).toEqual(['Nombre', 'Nota']);
|
||||||
|
expect(parsed.rows).toEqual([['Ana, con coma', 'dijo "hola"']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Minimal, dependency-free CSV export helper (HU-89). Turns a header row plus a
|
||||||
|
* matrix of cells into an RFC-4180-style CSV string and triggers a client-side
|
||||||
|
* download, so standings, goleadores and fixtures can be shared outside the app
|
||||||
|
* without any server round-trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CsvCellValue = string | number | boolean | null | undefined;
|
||||||
|
export type CsvRow = CsvCellValue[];
|
||||||
|
|
||||||
|
const CSV_DELIMITER = ',';
|
||||||
|
/** RFC-4180 records are separated by CRLF; Excel and Sheets both expect it. */
|
||||||
|
const CSV_LINE_BREAK = '\r\n';
|
||||||
|
/**
|
||||||
|
* UTF-8 BOM prepended to the download so spreadsheet apps (notably Excel on
|
||||||
|
* Windows) detect UTF-8 and render accented characters (á, ó, ñ) correctly.
|
||||||
|
*/
|
||||||
|
const UTF8_BOM = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a single CSV cell: renders null/undefined as empty, and wraps the
|
||||||
|
* value in double quotes (doubling any inner quote) whenever it contains a
|
||||||
|
* delimiter, a quote or a line break.
|
||||||
|
*/
|
||||||
|
const escapeCsvCell = (value: CsvCellValue): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(value);
|
||||||
|
if (/[",\r\n]/.test(text)) {
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a CSV string from a header row and data rows. Pure and
|
||||||
|
* side-effect-free so it can be unit-tested in isolation.
|
||||||
|
*/
|
||||||
|
export const buildCsv = (headers: string[], rows: CsvRow[]): string =>
|
||||||
|
[headers, ...rows]
|
||||||
|
.map(row => row.map(escapeCsvCell).join(CSV_DELIMITER))
|
||||||
|
.join(CSV_LINE_BREAK);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a CSV from the given headers/rows and triggers a browser download of
|
||||||
|
* it as `<filename>.csv`.
|
||||||
|
*/
|
||||||
|
export const downloadCsv = (
|
||||||
|
filename: string,
|
||||||
|
headers: string[],
|
||||||
|
rows: CsvRow[]
|
||||||
|
): void => {
|
||||||
|
const csv = `${UTF8_BOM}${buildCsv(headers, rows)}`;
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename.toLowerCase().endsWith('.csv')
|
||||||
|
? filename
|
||||||
|
: `${filename}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ParsedCsv {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits one CSV record into cells, undoing {@link escapeCsvCell}'s
|
||||||
|
* double-quote escaping (a `""` inside a quoted cell is a literal `"`). */
|
||||||
|
const parseCsvLine = (line: string): string[] => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (char === CSV_DELIMITER) {
|
||||||
|
cells.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push(current);
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses CSV text — the counterpart to {@link buildCsv} — into a header row
|
||||||
|
* and data rows of raw string cells. Blank lines are skipped so a trailing
|
||||||
|
* newline (or one left over from editing in a spreadsheet app) doesn't turn
|
||||||
|
* into a spurious empty row.
|
||||||
|
*/
|
||||||
|
export const parseCsv = (text: string): ParsedCsv => {
|
||||||
|
const lines = text
|
||||||
|
.replace(/^\uFEFF/, '')
|
||||||
|
.split(/\r\n|\n/)
|
||||||
|
.filter(line => line.trim() !== '');
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headerLine, ...dataLines] = lines;
|
||||||
|
return {
|
||||||
|
headers: parseCsvLine(headerLine).map(cell => cell.trim()),
|
||||||
|
rows: dataLines.map(parseCsvLine),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function handleFields(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const fields = Object.fromEntries(new window.FormData(event.currentTarget));
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
formatCalendarDate,
|
||||||
|
formatDateAr,
|
||||||
|
formatDateTimeAr,
|
||||||
|
formatTimeAr,
|
||||||
|
formatLongDateTimeAr,
|
||||||
|
toArDayKey,
|
||||||
|
formatArDayLabel,
|
||||||
|
} from './formatDate';
|
||||||
|
|
||||||
|
// Argentina (America/Argentina/Buenos_Aires) is UTC-3 year-round, so a UTC
|
||||||
|
// instant always renders three hours earlier. These assertions must hold
|
||||||
|
// regardless of the machine timezone the test runs on (HU-100).
|
||||||
|
describe('Argentina-time formatting helpers', () => {
|
||||||
|
it('formats a known UTC instant as AR date + time', () => {
|
||||||
|
expect(formatDateTimeAr('2026-08-16T17:30:00Z')).toBe('16/08/2026 14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats the date portion in AR time', () => {
|
||||||
|
expect(formatDateAr('2026-08-16T17:30:00Z')).toBe('16/08/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats the time portion in AR time', () => {
|
||||||
|
expect(formatTimeAr('2026-08-16T17:30:00Z')).toBe('14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back to the previous AR day for an early-UTC instant', () => {
|
||||||
|
// 02:00 UTC on Jan 1 is 23:00 the previous day in Buenos Aires.
|
||||||
|
expect(formatDateAr('2026-01-01T02:00:00Z')).toBe('31/12/2025');
|
||||||
|
expect(formatTimeAr('2026-01-01T02:00:00Z')).toBe('23:00');
|
||||||
|
expect(toArDayKey('2026-01-01T02:00:00Z')).toBe('2025-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a long Spanish AR date-time label', () => {
|
||||||
|
expect(formatLongDateTimeAr('2026-08-16T17:30:00Z')).toContain(
|
||||||
|
'16 de agosto de 2026 • 14:30'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a day key with a capitalized Spanish weekday', () => {
|
||||||
|
expect(formatArDayLabel('2026-08-16')).toContain('16 de agosto');
|
||||||
|
expect(formatArDayLabel('2026-08-16')[0]).toBe(
|
||||||
|
formatArDayLabel('2026-08-16')[0].toUpperCase()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns placeholders for empty or invalid input', () => {
|
||||||
|
expect(formatDateAr('')).toBe('—');
|
||||||
|
expect(formatDateTimeAr(null)).toBe('—');
|
||||||
|
expect(formatDateAr('not-a-date')).toBe('—');
|
||||||
|
expect(toArDayKey('')).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A pure calendar date (tournament start date, registration deadline, birth
|
||||||
|
// date) is submitted from an <input type="date"> as UTC midnight of the
|
||||||
|
// intended day ("2026-10-10" -> new Date() -> "2026-10-10T00:00:00.000Z").
|
||||||
|
// It has no real time-of-day, so — unlike a genuine instant — it must NOT be
|
||||||
|
// shifted into Argentina time on display: formatDateAr would roll UTC
|
||||||
|
// midnight back to 21:00 the previous day in Buenos Aires (UTC-3),
|
||||||
|
// displaying the wrong day for every viewer west of UTC.
|
||||||
|
describe('formatCalendarDate — pure date fields, no timezone shift', () => {
|
||||||
|
it('renders UTC midnight as the same calendar day, not the previous one', () => {
|
||||||
|
expect(formatCalendarDate('2026-10-10T00:00:00.000Z')).toBe('10/10/2026');
|
||||||
|
// formatDateAr on the same value demonstrates the bug this guards against.
|
||||||
|
expect(formatDateAr('2026-10-10T00:00:00.000Z')).toBe('09/10/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a placeholder for empty or invalid input', () => {
|
||||||
|
expect(formatCalendarDate('')).toBe('—');
|
||||||
|
expect(formatCalendarDate(null)).toBe('—');
|
||||||
|
expect(formatCalendarDate('not-a-date')).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import 'dayjs/locale/es';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical display timezone for the whole app (HU-100). The backend stores
|
||||||
|
* and returns instants in UTC; every user-facing date/time is presented in
|
||||||
|
* Argentina time regardless of the viewer's own timezone.
|
||||||
|
*/
|
||||||
|
export const AR_TIMEZONE = 'America/Argentina/Buenos_Aires';
|
||||||
|
|
||||||
|
/** Parses a UTC value and shifts it to Argentina time. */
|
||||||
|
const toArDayjs = (value: Date | string): Dayjs =>
|
||||||
|
dayjs.utc(value).tz(AR_TIMEZONE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a UTC value as a short Argentina-time date, e.g. "16/08/2026".
|
||||||
|
* Returns "—" for empty or unparseable input.
|
||||||
|
*/
|
||||||
|
export function formatDateAr(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a pure calendar date (no time-of-day meaning — a tournament's
|
||||||
|
* start date, a registration deadline, a birth date) as "16/08/2026".
|
||||||
|
*
|
||||||
|
* Unlike {@link formatDateAr}, this does NOT shift the value into Argentina
|
||||||
|
* time: a date-only field is submitted as UTC midnight of the intended day
|
||||||
|
* (`new Date("2026-10-10")` → `2026-10-10T00:00:00.000Z`) and stored as-is,
|
||||||
|
* so applying any timezone conversion on display — Argentina or otherwise —
|
||||||
|
* rolls it back to the previous day for any viewer west of UTC. Reading the
|
||||||
|
* value's UTC calendar-date components directly is the only correct way to
|
||||||
|
* round-trip a date-only value regardless of viewer timezone.
|
||||||
|
* Returns "—" for empty or unparseable input.
|
||||||
|
*/
|
||||||
|
export function formatCalendarDate(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = dayjs.utc(value);
|
||||||
|
return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a UTC value as a short Argentina-time date and time, e.g.
|
||||||
|
* "16/08/2026 14:30". Returns "—" for empty or unparseable input.
|
||||||
|
*/
|
||||||
|
export function formatDateTimeAr(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid() ? parsed.format('DD/MM/YYYY HH:mm') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a UTC value as an Argentina-time clock time, e.g. "14:30".
|
||||||
|
* Returns "—" for empty or unparseable input.
|
||||||
|
*/
|
||||||
|
export function formatTimeAr(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid() ? parsed.format('HH:mm') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a UTC value as a long Spanish Argentina-time date, with no
|
||||||
|
* time-of-day, e.g. "lunes, 16 de agosto de 2026". Returns "—" for
|
||||||
|
* empty/invalid input.
|
||||||
|
*/
|
||||||
|
export function formatLongDateAr(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid() ? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a UTC value as a long Spanish Argentina-time date and time, e.g.
|
||||||
|
* "lunes, 16 de agosto de 2026 • 14:30". Returns "—" for empty/invalid input.
|
||||||
|
*/
|
||||||
|
export function formatLongDateTimeAr(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid()
|
||||||
|
? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY • HH:mm')
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Argentina-time calendar-day key ("YYYY-MM-DD") for a UTC value, or
|
||||||
|
* "unknown" for empty/invalid input. Used to group items (e.g. fixtures) by
|
||||||
|
* their Argentina-time day so an instant near midnight lands on the day the
|
||||||
|
* user actually sees, not the UTC day.
|
||||||
|
*/
|
||||||
|
export function toArDayKey(value?: Date | string | null): string {
|
||||||
|
if (!value) return 'unknown';
|
||||||
|
const parsed = toArDayjs(value);
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a "YYYY-MM-DD" day key (see toArDayKey) as a long, capitalized
|
||||||
|
* Spanish weekday + day + month label, e.g. "Jueves, 1 de enero".
|
||||||
|
*/
|
||||||
|
export function formatArDayLabel(dayKey: string): string {
|
||||||
|
const parsed = dayjs(dayKey);
|
||||||
|
if (!parsed.isValid()) return 'Fecha a confirmar';
|
||||||
|
const label = parsed.locale('es').format('dddd, D [de] MMMM');
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a UTC date string to a formatted Argentina-time date string in
|
||||||
|
* Spanish, e.g. "lunes, 16 de agosto de 2026 • 14:30".
|
||||||
|
* @param dateString - The date string in UTC format.
|
||||||
|
*/
|
||||||
|
export function formatMatchDateToString(dateString: string): string {
|
||||||
|
return formatLongDateTimeAr(dateString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a UTC date string to a local Date object.
|
||||||
|
* @param dateString - The date string in UTC format.
|
||||||
|
* @returns A JavaScript Date object in the local timezone.
|
||||||
|
*/
|
||||||
|
export function parseUTCToLocalDate(dateString: string): Date {
|
||||||
|
if (!dateString) return new Date(NaN);
|
||||||
|
|
||||||
|
return dayjs.utc(dateString).local().toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Date object (assumed UTC) to a local Date object.
|
||||||
|
* @param date - The Date object in UTC.
|
||||||
|
* @returns A JavaScript Date object in the local timezone.
|
||||||
|
*/
|
||||||
|
export function convertToLocalDate(date: Date): Date {
|
||||||
|
if (!date) return new Date(NaN);
|
||||||
|
|
||||||
|
return dayjs.utc(date).local().toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Date to a string compatible with <input type="datetime-local"> in local time.
|
||||||
|
* @param date - The Date object (UTC or local)
|
||||||
|
* @returns string in "YYYY-MM-DDTHH:mm" format
|
||||||
|
*/
|
||||||
|
export function formatDateTimeInput(date: Date): string {
|
||||||
|
if (!date) return '';
|
||||||
|
return dayjs(date).local().format('YYYY-MM-DDTHH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an ISO/UTC date string into the "YYYY-MM-DDTHH:mm" value an
|
||||||
|
* <input type="datetime-local"> needs, or "" for empty/unparseable input.
|
||||||
|
* Used to preload a datetime-local field (e.g. a match's date when editing it
|
||||||
|
* or issuing a sanction from that match).
|
||||||
|
*/
|
||||||
|
export function toDatetimeLocalValue(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const parsed = new Date(iso);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? '' : formatDateTimeInput(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares whether a deadline date is after the current date.
|
||||||
|
* @param {Date} deadline - The deadline to compare.
|
||||||
|
* @returns {boolean} - 'true' if the deadline has not yet passed, 'false' if it has already passed.
|
||||||
|
*/
|
||||||
|
export const isDeadlineInTheFuture = (deadline: Date): boolean => {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(deadline) > now;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { geocodeAddress } from './geocoding';
|
||||||
|
|
||||||
|
const mockFetch = (response: Partial<Response> & { jsonBody?: unknown }) => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: response.ok ?? true,
|
||||||
|
json: () => Promise.resolve(response.jsonBody ?? []),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geocodeAddress', () => {
|
||||||
|
it('returns the first result\'s coordinates', async () => {
|
||||||
|
mockFetch({ jsonBody: [{ lat: '-34.6037', lon: '-58.3816' }] });
|
||||||
|
|
||||||
|
const result = await geocodeAddress('Av. Corrientes 1000, CABA');
|
||||||
|
|
||||||
|
expect(result).toEqual({ latitude: -34.6037, longitude: -58.3816 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an empty address without calling the network', async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const result = await geocodeAddress(' ');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the search yields no results', async () => {
|
||||||
|
mockFetch({ jsonBody: [] });
|
||||||
|
|
||||||
|
const result = await geocodeAddress('an address that does not exist anywhere');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the request fails', async () => {
|
||||||
|
mockFetch({ ok: false });
|
||||||
|
|
||||||
|
const result = await geocodeAddress('some address');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the network throws', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
||||||
|
|
||||||
|
const result = await geocodeAddress('some address');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Looks up an address's coordinates via OpenStreetMap's free Nominatim
|
||||||
|
* search API (no API key required). Callers must debounce their own calls
|
||||||
|
* (e.g. on a pause in typing) rather than firing on every keystroke, per
|
||||||
|
* Nominatim's usage policy (https://operations.osmfoundation.org/policies/nominatim/).
|
||||||
|
* @param address The free-text address to look up.
|
||||||
|
* @returns The first match's coordinates, or null if nothing was found or the
|
||||||
|
* lookup failed.
|
||||||
|
*/
|
||||||
|
export const geocodeAddress = async (
|
||||||
|
address: string
|
||||||
|
): Promise<{ latitude: number; longitude: number } | null> => {
|
||||||
|
const trimmed = address.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(trimmed)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ lat: string; lon: string }> = await response.json();
|
||||||
|
const [first] = results;
|
||||||
|
if (!first) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = Number(first.lat);
|
||||||
|
const longitude = Number(first.lon);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { latitude, longitude };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { sendGet } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
import {
|
||||||
|
dismissMaintenanceBanner,
|
||||||
|
getMaintenanceBannerSnapshot,
|
||||||
|
subscribeMaintenanceBanner,
|
||||||
|
} from '@/modules/core/utils/maintenanceBanner';
|
||||||
|
|
||||||
|
vi.mock('axios', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('axios')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: {
|
||||||
|
...actual.default,
|
||||||
|
request: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const build503Error = (): AxiosError =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: 'Request failed with status code 503',
|
||||||
|
config: { headers: {} },
|
||||||
|
response: {
|
||||||
|
status: HttpStatus.ServiceUnavailable,
|
||||||
|
data: { message: 'La base de datos está en mantenimiento.' },
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: {} },
|
||||||
|
},
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as unknown as AxiosError;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
dismissMaintenanceBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dismissMaintenanceBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maintenance banner — registered against the axiosUtils handler registry', () => {
|
||||||
|
it('starts inactive', () => {
|
||||||
|
expect(getMaintenanceBannerSnapshot()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips active when any request receives a 503, via onStatusCode(HttpStatus.ServiceUnavailable, ...)', async () => {
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
|
||||||
|
|
||||||
|
await expect(sendGet('backups')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(getMaintenanceBannerSnapshot()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers when the banner flips active', async () => {
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
|
||||||
|
let notified = false;
|
||||||
|
const unsubscribe = subscribeMaintenanceBanner(() => {
|
||||||
|
notified = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sendGet('backups')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(notified).toBe(true);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flip the banner for an unrelated status code (e.g. 404)', async () => {
|
||||||
|
const notFoundError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: 'Request failed with status code 404',
|
||||||
|
config: { headers: {} },
|
||||||
|
response: {
|
||||||
|
status: HttpStatus.NotFound,
|
||||||
|
data: {},
|
||||||
|
statusText: 'Not Found',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: {} },
|
||||||
|
},
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as unknown as AxiosError;
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(notFoundError);
|
||||||
|
|
||||||
|
await expect(sendGet('backups')).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(getMaintenanceBannerSnapshot()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismissMaintenanceBanner resets the banner to inactive and notifies subscribers', async () => {
|
||||||
|
vi.mocked(axios.request).mockRejectedValueOnce(build503Error());
|
||||||
|
await expect(sendGet('backups')).rejects.toBeTruthy();
|
||||||
|
expect(getMaintenanceBannerSnapshot()).toBe(true);
|
||||||
|
|
||||||
|
let notified = false;
|
||||||
|
const unsubscribe = subscribeMaintenanceBanner(() => {
|
||||||
|
notified = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
dismissMaintenanceBanner();
|
||||||
|
|
||||||
|
expect(getMaintenanceBannerSnapshot()).toBe(false);
|
||||||
|
expect(notified).toBe(true);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { onStatusCode } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global "the database is in maintenance" banner state, flipped by any
|
||||||
|
* request that comes back `503 Service Unavailable` (the restore-in-progress
|
||||||
|
* gate from `MaintenanceModeMiddleware`). Deliberately a plain module-level
|
||||||
|
* subscribable store — not a React context — so it can be wired against
|
||||||
|
* `onStatusCode` at import time, the same already-supported extension point
|
||||||
|
* `onUnauthorized` uses, with no axios interceptor rewrite.
|
||||||
|
*/
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
let isActive = false;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
const notify = (): void => {
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the maintenance banner active and notifies every subscriber.
|
||||||
|
*/
|
||||||
|
export const activateMaintenanceBanner = (): void => {
|
||||||
|
isActive = true;
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the maintenance banner inactive and notifies every subscriber.
|
||||||
|
* Used once maintenance mode is confirmed cleared (e.g. after a manual
|
||||||
|
* `DELETE /api/maintenance` escape hatch, or after the banner UI dismisses).
|
||||||
|
*/
|
||||||
|
export const dismissMaintenanceBanner = (): void => {
|
||||||
|
isActive = false;
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current banner state, suitable as a `useSyncExternalStore` snapshot.
|
||||||
|
* @returns {boolean} Whether the maintenance banner is currently active.
|
||||||
|
*/
|
||||||
|
export const getMaintenanceBannerSnapshot = (): boolean => isActive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to banner state changes.
|
||||||
|
* @param {Listener} listener - Called with no arguments whenever the banner flips.
|
||||||
|
* @returns {() => void} Unsubscribe function.
|
||||||
|
*/
|
||||||
|
export const subscribeMaintenanceBanner = (listener: Listener): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
onStatusCode(HttpStatus.ServiceUnavailable, () => {
|
||||||
|
activateMaintenanceBanner();
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_METADATA,
|
||||||
|
resetPageMetadata,
|
||||||
|
setPageMetadata,
|
||||||
|
toAbsoluteUrl,
|
||||||
|
} from '@/modules/core/utils/pageMetadata';
|
||||||
|
|
||||||
|
const metaContent = (attribute: 'property' | 'name', key: string) =>
|
||||||
|
document.head
|
||||||
|
.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`)
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
const canonicalHref = () =>
|
||||||
|
document.head
|
||||||
|
.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||||
|
?.getAttribute('href');
|
||||||
|
|
||||||
|
describe('toAbsoluteUrl', () => {
|
||||||
|
it('returns undefined for an empty path', () => {
|
||||||
|
expect(toAbsoluteUrl(undefined, 'https://club12.com')).toBeUndefined();
|
||||||
|
expect(toAbsoluteUrl('', 'https://club12.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an already-absolute URL untouched', () => {
|
||||||
|
expect(toAbsoluteUrl('https://cdn/x.png', 'https://club12.com')).toBe(
|
||||||
|
'https://cdn/x.png'
|
||||||
|
);
|
||||||
|
expect(toAbsoluteUrl('http://cdn/x.png', 'https://club12.com')).toBe(
|
||||||
|
'http://cdn/x.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a root-relative path onto the origin', () => {
|
||||||
|
expect(toAbsoluteUrl('/assets/logo.png', 'https://club12.com')).toBe(
|
||||||
|
'https://club12.com/assets/logo.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a bare path onto the origin with a separator', () => {
|
||||||
|
expect(toAbsoluteUrl('assets/logo.png', 'https://club12.com')).toBe(
|
||||||
|
'https://club12.com/assets/logo.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the relative path unchanged when there is no origin', () => {
|
||||||
|
expect(toAbsoluteUrl('/assets/logo.png', '')).toBe('/assets/logo.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pageMetadata', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.head.querySelectorAll('meta').forEach(meta => meta.remove());
|
||||||
|
document.head
|
||||||
|
.querySelectorAll('link[rel="canonical"]')
|
||||||
|
.forEach(link => link.remove());
|
||||||
|
document.title = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes Open Graph and Twitter tags for a post (HU-17)', () => {
|
||||||
|
setPageMetadata({
|
||||||
|
title: 'Gran final',
|
||||||
|
description: 'Resumen del partido',
|
||||||
|
image: 'https://cdn.club12/photo.png',
|
||||||
|
url: 'https://club12/blog/gran-final',
|
||||||
|
type: 'article',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toBe('Gran final · Club 12');
|
||||||
|
expect(metaContent('property', 'og:title')).toBe('Gran final');
|
||||||
|
expect(metaContent('property', 'og:description')).toBe(
|
||||||
|
'Resumen del partido'
|
||||||
|
);
|
||||||
|
expect(metaContent('property', 'og:image')).toBe(
|
||||||
|
'https://cdn.club12/photo.png'
|
||||||
|
);
|
||||||
|
expect(metaContent('property', 'og:url')).toBe(
|
||||||
|
'https://club12/blog/gran-final'
|
||||||
|
);
|
||||||
|
expect(metaContent('property', 'og:type')).toBe('article');
|
||||||
|
expect(metaContent('name', 'twitter:card')).toBe('summary_large_image');
|
||||||
|
expect(metaContent('name', 'twitter:title')).toBe('Gran final');
|
||||||
|
expect(metaContent('name', 'twitter:image')).toBe(
|
||||||
|
'https://cdn.club12/photo.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a plain summary card when there is no image', () => {
|
||||||
|
setPageMetadata({ title: 'Sin imagen', description: 'Texto' });
|
||||||
|
|
||||||
|
expect(metaContent('name', 'twitter:card')).toBe('summary');
|
||||||
|
expect(metaContent('property', 'og:image')).toBeUndefined();
|
||||||
|
expect(metaContent('name', 'twitter:image')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset restores the site defaults', () => {
|
||||||
|
setPageMetadata({ title: 'Gran final', description: 'x' });
|
||||||
|
resetPageMetadata();
|
||||||
|
|
||||||
|
expect(document.title).toBe('Club 12');
|
||||||
|
expect(metaContent('property', 'og:title')).toBe(
|
||||||
|
DEFAULT_PAGE_METADATA.title
|
||||||
|
);
|
||||||
|
expect(metaContent('property', 'og:description')).toBe(
|
||||||
|
DEFAULT_PAGE_METADATA.description
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a canonical link defaulting to the current origin + path', () => {
|
||||||
|
setPageMetadata({ title: 'Campeones' });
|
||||||
|
|
||||||
|
const canonical = canonicalHref();
|
||||||
|
expect(canonical).toBeDefined();
|
||||||
|
expect(canonical).toBe(
|
||||||
|
`${window.location.origin}${window.location.pathname}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an explicit canonical url', () => {
|
||||||
|
setPageMetadata({ title: 'Post', url: 'https://club12.com/blog/x' });
|
||||||
|
|
||||||
|
expect(canonicalHref()).toBe('https://club12.com/blog/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-page social/SEO metadata (HU-17). Because the app is a client-rendered
|
||||||
|
* SPA (no SSR), these tags are written into the document head at runtime. That
|
||||||
|
* covers on-page sharing widgets and crawlers that execute JavaScript; static
|
||||||
|
* scrapers that never run JS will still read the index.html defaults.
|
||||||
|
*/
|
||||||
|
export interface PageMetadata {
|
||||||
|
/** Document title / og:title / twitter:title. */
|
||||||
|
title?: string;
|
||||||
|
/** Meta description / og:description / twitter:description. */
|
||||||
|
description?: string;
|
||||||
|
/** Absolute image URL for og:image / twitter:image. */
|
||||||
|
image?: string;
|
||||||
|
/** Canonical URL for og:url. Defaults to the current location. */
|
||||||
|
url?: string;
|
||||||
|
/** og:type. Defaults to "website". */
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_NAME = 'Club 12';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default social-share image (SEO). A root-relative path lives in `public/`;
|
||||||
|
* it is resolved to an absolute URL against the current origin at runtime so
|
||||||
|
* scrapers get a fully-qualified `og:image`.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_OG_IMAGE = '/assets/logo-club12.png';
|
||||||
|
|
||||||
|
/** The site-wide default metadata restored when a page unmounts (HU-17). */
|
||||||
|
export const DEFAULT_PAGE_METADATA: PageMetadata = {
|
||||||
|
title: SITE_NAME,
|
||||||
|
description:
|
||||||
|
'La liga de básquet amateur con más historia de la zona. Torneos, ' +
|
||||||
|
'resultados y estadísticas de todas las divisiones en un solo lugar.',
|
||||||
|
type: 'website',
|
||||||
|
image: DEFAULT_OG_IMAGE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a possibly-relative asset/URL path into an absolute URL against the
|
||||||
|
* given origin. Absolute inputs (http/https) pass through unchanged; empty
|
||||||
|
* inputs yield `undefined`; when there is no origin (SSR/tests) the relative
|
||||||
|
* path is returned as-is. Pure — safe to unit test.
|
||||||
|
*/
|
||||||
|
export const toAbsoluteUrl = (
|
||||||
|
path: string | undefined,
|
||||||
|
origin: string
|
||||||
|
): string | undefined => {
|
||||||
|
if (!path) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!origin) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.startsWith('/') ? `${origin}${path}` : `${origin}/${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertCanonical = (href?: string): void => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = 'link[rel="canonical"]';
|
||||||
|
let element = document.head.querySelector<HTMLLinkElement>(selector);
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
element?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('link');
|
||||||
|
element.setAttribute('rel', 'canonical');
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.setAttribute('href', href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertMeta = (
|
||||||
|
attribute: 'property' | 'name',
|
||||||
|
key: string,
|
||||||
|
content?: string
|
||||||
|
): void => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = `meta[${attribute}="${key}"]`;
|
||||||
|
let element = document.head.querySelector<HTMLMetaElement>(selector);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
element?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('meta');
|
||||||
|
element.setAttribute(attribute, key);
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.setAttribute('content', content);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the given Open Graph / Twitter Card metadata into the document head,
|
||||||
|
* merging over the site defaults (HU-17). Title and description always fall
|
||||||
|
* back to the defaults; image and url are only emitted when provided.
|
||||||
|
*
|
||||||
|
* @param metadata The page-specific overrides.
|
||||||
|
*/
|
||||||
|
export const setPageMetadata = (metadata: PageMetadata): void => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = metadata.title ?? DEFAULT_PAGE_METADATA.title;
|
||||||
|
const description =
|
||||||
|
metadata.description ?? DEFAULT_PAGE_METADATA.description;
|
||||||
|
const type = metadata.type ?? DEFAULT_PAGE_METADATA.type;
|
||||||
|
const origin =
|
||||||
|
typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
const pathname =
|
||||||
|
typeof window !== 'undefined' ? window.location.pathname : '';
|
||||||
|
// Canonical (and og:url) omit query/hash so tab/filter permutations of a page
|
||||||
|
// collapse to one indexable URL.
|
||||||
|
const canonical =
|
||||||
|
metadata.url ?? (origin ? `${origin}${pathname}` : undefined);
|
||||||
|
const image = toAbsoluteUrl(metadata.image, origin);
|
||||||
|
|
||||||
|
const documentTitle =
|
||||||
|
title && title !== SITE_NAME ? `${title} · ${SITE_NAME}` : SITE_NAME;
|
||||||
|
document.title = documentTitle;
|
||||||
|
|
||||||
|
upsertMeta('name', 'description', description);
|
||||||
|
|
||||||
|
upsertMeta('property', 'og:site_name', SITE_NAME);
|
||||||
|
upsertMeta('property', 'og:title', title);
|
||||||
|
upsertMeta('property', 'og:description', description);
|
||||||
|
upsertMeta('property', 'og:type', type);
|
||||||
|
upsertMeta('property', 'og:url', canonical);
|
||||||
|
upsertMeta('property', 'og:image', image);
|
||||||
|
|
||||||
|
upsertMeta('name', 'twitter:card', image ? 'summary_large_image' : 'summary');
|
||||||
|
upsertMeta('name', 'twitter:title', title);
|
||||||
|
upsertMeta('name', 'twitter:description', description);
|
||||||
|
upsertMeta('name', 'twitter:image', image);
|
||||||
|
|
||||||
|
upsertCanonical(canonical);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Restores the site-wide default metadata (HU-17). */
|
||||||
|
export const resetPageMetadata = (): void => {
|
||||||
|
setPageMetadata(DEFAULT_PAGE_METADATA);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that applies per-page metadata on mount/update and restores the
|
||||||
|
* site defaults on unmount (HU-17). Pass a stable/serialisable metadata object.
|
||||||
|
*
|
||||||
|
* @param metadata The page-specific metadata to apply.
|
||||||
|
*/
|
||||||
|
export const usePageMetadata = (metadata: PageMetadata): void => {
|
||||||
|
const { title, description, image, url, type } = metadata;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageMetadata({ title, description, image, url, type });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resetPageMetadata();
|
||||||
|
};
|
||||||
|
}, [title, description, image, url, type]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* `@media print` isolation: hide every element on the page except the
|
||||||
|
* subtree tagged `[data-print="sheet"]`, and force that subtree visible even
|
||||||
|
* though it is `display:none` on screen. This hides all app chrome
|
||||||
|
* (nav/tabs/buttons) without needing to tag them individually. Shared by
|
||||||
|
* every print-only sheet (standings, goleadores, …) so they all get the
|
||||||
|
* exact same isolation behavior from one place. Import and render via
|
||||||
|
* `<GlobalStyles styles={printMediaStyles} />` in each sheet component —
|
||||||
|
* only the currently-mounted sheet's `[data-print="sheet"]` node matters, so
|
||||||
|
* it is safe for more than one sheet component to inject this at once.
|
||||||
|
*/
|
||||||
|
export const printMediaStyles = {
|
||||||
|
'@media print': {
|
||||||
|
'body *': { visibility: 'hidden' },
|
||||||
|
'[data-print="sheet"], [data-print="sheet"] *': { visibility: 'visible' },
|
||||||
|
'[data-print="sheet"]': {
|
||||||
|
display: 'block !important',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
'[data-print="hide"]': { display: 'none !important' },
|
||||||
|
thead: { display: 'table-header-group' },
|
||||||
|
tr: { breakInside: 'avoid' },
|
||||||
|
'*': {
|
||||||
|
printColorAdjust: 'exact',
|
||||||
|
WebkitPrintColorAdjust: 'exact',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated outcome of a mutation that can fail with a user-facing reason
|
||||||
|
* (e.g. a 409 Conflict the backend returns with a Spanish message). Callers
|
||||||
|
* surface {@link errorMessage} inline instead of routing the failure through
|
||||||
|
* the global error handler.
|
||||||
|
*/
|
||||||
|
export type MutationResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; errorMessage: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the ProblemDetails `detail` string from an Axios error response, when
|
||||||
|
* present. The backend returns the raw business message there for 4xx errors.
|
||||||
|
*
|
||||||
|
* @param error The error thrown by a request.
|
||||||
|
* @returns The `detail` message, or undefined when it is not an Axios error
|
||||||
|
* with a string detail.
|
||||||
|
*/
|
||||||
|
export const extractProblemDetail = (error: unknown): string | undefined => {
|
||||||
|
if (!(error instanceof AxiosError)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = error.response?.data as { detail?: unknown } | undefined;
|
||||||
|
return typeof data?.detail === 'string' ? data.detail : undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
beginRequest,
|
||||||
|
clearBlockingMessage,
|
||||||
|
endRequest,
|
||||||
|
getActiveRequestCount,
|
||||||
|
getBlockingMessage,
|
||||||
|
runWithBlockingMessage,
|
||||||
|
setBlockingMessage,
|
||||||
|
subscribeToRequestActivity,
|
||||||
|
} from './requestActivity';
|
||||||
|
|
||||||
|
describe('requestActivity', () => {
|
||||||
|
it('tracks nested begin/end calls and notifies subscribers with the running count', () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const unsubscribe = subscribeToRequestActivity(count => seen.push(count));
|
||||||
|
|
||||||
|
beginRequest();
|
||||||
|
beginRequest();
|
||||||
|
endRequest();
|
||||||
|
endRequest();
|
||||||
|
|
||||||
|
expect(seen).toEqual([1, 2, 1, 0]);
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never goes negative when endRequest is called without a matching begin', () => {
|
||||||
|
endRequest();
|
||||||
|
expect(getActiveRequestCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops notifying a listener after it unsubscribes', () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const unsubscribe = subscribeToRequestActivity(count => seen.push(count));
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
beginRequest();
|
||||||
|
endRequest();
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestActivity — blocking message', () => {
|
||||||
|
it('has no message until one is set, and the newest set message wins', () => {
|
||||||
|
expect(getBlockingMessage()).toBeNull();
|
||||||
|
|
||||||
|
const first = setBlockingMessage('Restaurando…');
|
||||||
|
expect(getBlockingMessage()).toBe('Restaurando…');
|
||||||
|
|
||||||
|
const second = setBlockingMessage('Generando…');
|
||||||
|
expect(getBlockingMessage()).toBe('Generando…');
|
||||||
|
|
||||||
|
clearBlockingMessage(second);
|
||||||
|
expect(getBlockingMessage()).toBe('Restaurando…');
|
||||||
|
|
||||||
|
clearBlockingMessage(first);
|
||||||
|
expect(getBlockingMessage()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates clearing messages out of order', () => {
|
||||||
|
const a = setBlockingMessage('A');
|
||||||
|
const b = setBlockingMessage('B');
|
||||||
|
|
||||||
|
clearBlockingMessage(a);
|
||||||
|
expect(getBlockingMessage()).toBe('B');
|
||||||
|
|
||||||
|
clearBlockingMessage(b);
|
||||||
|
expect(getBlockingMessage()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers when the message changes', () => {
|
||||||
|
let calls = 0;
|
||||||
|
const unsubscribe = subscribeToRequestActivity(() => {
|
||||||
|
calls += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = setBlockingMessage('X');
|
||||||
|
clearBlockingMessage(id);
|
||||||
|
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runWithBlockingMessage shows the message around the operation and clears it even on throw', async () => {
|
||||||
|
await runWithBlockingMessage('Trabajando…', async () => {
|
||||||
|
expect(getBlockingMessage()).toBe('Trabajando…');
|
||||||
|
});
|
||||||
|
expect(getBlockingMessage()).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithBlockingMessage('Fallando…', async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect(getBlockingMessage()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
type Listener = (activeCount: number) => void;
|
||||||
|
|
||||||
|
let activeCount = 0;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
// A LIFO stack of contextual overlay messages. Each is pushed with a unique id
|
||||||
|
// so it can be popped out of order (nested/overlapping operations); the newest
|
||||||
|
// one wins as the visible message. Empty stack => no message, just the spinner.
|
||||||
|
const messageStack: { id: number; text: string }[] = [];
|
||||||
|
let nextMessageId = 1;
|
||||||
|
|
||||||
|
const notify = (): void => {
|
||||||
|
listeners.forEach(listener => listener(activeCount));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny external store tracking how many mutating HTTP requests (POST/PUT/
|
||||||
|
* DELETE — loading/uploading/saving something that can take a moment) are in
|
||||||
|
* flight right now. `axiosUtils.sendRequest` is the single choke point every
|
||||||
|
* request goes through, so it increments/decrements this instead of each
|
||||||
|
* screen tracking its own `submitting` flag — `GlobalLoadingOverlay`
|
||||||
|
* subscribes to it once, at the app root, so ANY save/upload blocks the
|
||||||
|
* whole screen with a spinner without every call site wiring it manually.
|
||||||
|
* GET requests are intentionally excluded: page data already has its own
|
||||||
|
* skeleton-loading convention, and blocking the screen on every background
|
||||||
|
* fetch would be a jarring regression (see the "no blocking modals for GETs"
|
||||||
|
* rule the public pages already follow).
|
||||||
|
*/
|
||||||
|
export const beginRequest = (): void => {
|
||||||
|
activeCount += 1;
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const endRequest = (): void => {
|
||||||
|
activeCount = Math.max(0, activeCount - 1);
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveRequestCount = (): number => activeCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a contextual message on the global blocking overlay for the duration of
|
||||||
|
* a long operation (e.g. "Restaurando la base de datos. No cierres esta
|
||||||
|
* página…"). Returns an id to pass to {@link clearBlockingMessage}. Prefer
|
||||||
|
* {@link runWithBlockingMessage}, which pairs the two automatically.
|
||||||
|
*/
|
||||||
|
export const setBlockingMessage = (text: string): number => {
|
||||||
|
const id = nextMessageId++;
|
||||||
|
messageStack.push({ id, text });
|
||||||
|
notify();
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearBlockingMessage = (id: number): void => {
|
||||||
|
const index = messageStack.findIndex(entry => entry.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
messageStack.splice(index, 1);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlockingMessage = (): string | null =>
|
||||||
|
messageStack.length > 0 ? messageStack[messageStack.length - 1].text : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `operation` with `message` shown on the global blocking overlay, always
|
||||||
|
* clearing it afterwards (success or throw). The overlay is already visible for
|
||||||
|
* any mutating request in flight; this only adds the contextual text.
|
||||||
|
*/
|
||||||
|
export const runWithBlockingMessage = async <T>(
|
||||||
|
message: string,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
const id = setBlockingMessage(message);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
clearBlockingMessage(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeToRequestActivity = (listener: Listener): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
export function upsertListById<T extends { id: GUID }>(
|
||||||
|
list: T[] | null | undefined,
|
||||||
|
item: T
|
||||||
|
): T[] {
|
||||||
|
const safeList = list ?? [];
|
||||||
|
|
||||||
|
const index = safeList.findIndex(x => x.id === item.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const newList = [...safeList];
|
||||||
|
newList[index] = item;
|
||||||
|
return newList;
|
||||||
|
} else {
|
||||||
|
return [...safeList, item];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { StageType } from '@/modules/stage/type/stage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spanish translations for each StageType value.
|
||||||
|
* Using `satisfies` enforces exhaustiveness at compile time.
|
||||||
|
*/
|
||||||
|
const STAGE_TYPE_ES = {
|
||||||
|
[StageType.Group]: 'Fase de grupos',
|
||||||
|
[StageType.RoundOf16]: 'Octavos de final',
|
||||||
|
[StageType.QuarterFinal]: 'Cuartos de final',
|
||||||
|
[StageType.SemiFinal]: 'Semifinal',
|
||||||
|
[StageType.ThirdPlace]: 'Tercer puesto',
|
||||||
|
[StageType.Final]: 'Final',
|
||||||
|
} satisfies Record<StageType, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a StageType enum value into its Spanish equivalent.
|
||||||
|
*/
|
||||||
|
export const translateStageType = (stageType: StageType): string =>
|
||||||
|
STAGE_TYPE_ES[stageType];
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
formatArgentinePhone,
|
||||||
|
formatDocumentNumber,
|
||||||
|
isAtLeastMinimumPlayerAge,
|
||||||
|
isValidDocumentNumber,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPhone,
|
||||||
|
} from './validators';
|
||||||
|
|
||||||
|
describe('isValidEmail', () => {
|
||||||
|
it.each([
|
||||||
|
'user@example.com',
|
||||||
|
'jugador.12@club.com.ar',
|
||||||
|
'a@b.co',
|
||||||
|
' spaced@example.com ',
|
||||||
|
])('accepts a valid email: %s', email => {
|
||||||
|
expect(isValidEmail(email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'plainaddress',
|
||||||
|
'missing@domain',
|
||||||
|
'@no-local.com',
|
||||||
|
'no-at-sign.com',
|
||||||
|
'spaces in@email.com',
|
||||||
|
'double@@example.com',
|
||||||
|
])('rejects an invalid email: %s', email => {
|
||||||
|
expect(isValidEmail(email)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidPhone', () => {
|
||||||
|
it.each(['1123456789', '11 2345-6789', '(11) 2345 6789', '3431234567'])(
|
||||||
|
'accepts a 10-digit national number: %s',
|
||||||
|
phone => {
|
||||||
|
expect(isValidPhone(phone)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['', 'empty'],
|
||||||
|
['123', 'too few digits'],
|
||||||
|
['1234567', '7 digits, below the 10-digit national length'],
|
||||||
|
['01123456789', '11 digits — the 0 long-distance prefix is not accepted'],
|
||||||
|
['91123456789', '11 digits — the 9 mobile marker is not accepted'],
|
||||||
|
['+54 11 2345-6789', '12 digits — the +54 country code is not accepted'],
|
||||||
|
['+54 9 11 2345-6789', '13 digits — +54 9 is not accepted'],
|
||||||
|
['abc1234567', 'letters not allowed'],
|
||||||
|
['11 2345 6789 ext.4', 'letters not allowed'],
|
||||||
|
])('rejects an invalid phone: %s (%s)', phone => {
|
||||||
|
expect(isValidPhone(phone)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only digits, ignoring separators', () => {
|
||||||
|
expect(isValidPhone('(11) 1234-5678')).toBe(true);
|
||||||
|
expect(isValidPhone('1-2-3-4-5-6-7')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidDocumentNumber', () => {
|
||||||
|
it.each(['30111222', '1234567', '999999999999999'])(
|
||||||
|
'accepts a digits-only document number: %s',
|
||||||
|
value => {
|
||||||
|
expect(isValidDocumentNumber(value)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'', // empty
|
||||||
|
'd23', // letters
|
||||||
|
'12345', // too short (5 digits)
|
||||||
|
'1234567890123456', // too long (16 digits)
|
||||||
|
'30.111.222', // formatted with dots — reject, not accept-and-strip
|
||||||
|
])('rejects a non-digits-only document number: %s', value => {
|
||||||
|
expect(isValidDocumentNumber(value)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatArgentinePhone', () => {
|
||||||
|
it('formats a bare 10-digit local number in the national shape', () => {
|
||||||
|
expect(formatArgentinePhone('3435551234')).toBe('343 555-1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips separators from an already-formatted number', () => {
|
||||||
|
expect(formatArgentinePhone('343 555-1234')).toBe('343 555-1234');
|
||||||
|
expect(formatArgentinePhone('(343) 555-1234')).toBe('343 555-1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a number of unexpected length unchanged', () => {
|
||||||
|
expect(formatArgentinePhone('123')).toBe('123');
|
||||||
|
expect(formatArgentinePhone('123456789012')).toBe('123456789012');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDocumentNumber', () => {
|
||||||
|
it('adds dot thousands-separators to a valid document number', () => {
|
||||||
|
expect(formatDocumentNumber('38742615')).toBe('38.742.615');
|
||||||
|
expect(formatDocumentNumber('412281692')).toBe('412.281.692');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns non-numeric input unchanged instead of mangling it', () => {
|
||||||
|
expect(formatDocumentNumber('d23')).toBe('d23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAtLeastMinimumPlayerAge', () => {
|
||||||
|
// Builds "YYYY-MM-DD" from LOCAL date parts (not toISOString, which
|
||||||
|
// converts to UTC and can shift the calendar date near local midnight
|
||||||
|
// in timezones behind UTC, e.g. Argentina).
|
||||||
|
const isoDateYearsAgo = (years: number): string => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setFullYear(date.getFullYear() - years);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts someone older than the minimum age', () => {
|
||||||
|
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(20))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts someone exactly at the minimum age', () => {
|
||||||
|
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(15))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects someone younger than the minimum age', () => {
|
||||||
|
expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(10))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unparsable date', () => {
|
||||||
|
expect(isAtLeastMinimumPlayerAge('not-a-date')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Shared email / phone validators reused by every form that collects contact
|
||||||
|
* data (user create/invite/edit, player create/edit, password recovery).
|
||||||
|
*
|
||||||
|
* The rules intentionally mirror the backend DataAnnotations in
|
||||||
|
* `Application.Utils.Constants.Validation.ValidationPatterns`, so the client and
|
||||||
|
* the server accept exactly the same values and the user never gets a 400 for
|
||||||
|
* input the form said was fine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Standard, permissive email shape: local@domain.tld with no whitespace. */
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/** Characters allowed in a plausible phone number. */
|
||||||
|
const PHONE_ALLOWED_CHARS_REGEX = /^[+\d\s()-]+$/;
|
||||||
|
|
||||||
|
/** A player's DNI/document number: digits only, 6 to 15 of them. */
|
||||||
|
const DOCUMENT_NUMBER_REGEX = /^\d{6,15}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASP.NET Core Identity's default `IdentityOptions.User.AllowedUserNameCharacters`
|
||||||
|
* (letters, digits, and `-._@+`) — mirrored here so an invalid username (most
|
||||||
|
* commonly one with spaces, since the field's Spanish label "Nombre de
|
||||||
|
* usuario" reads like it could take a full name) is caught client-side with a
|
||||||
|
* Spanish message, instead of round-tripping to the backend and surfacing its
|
||||||
|
* raw English Identity error ("Username '...' is invalid, can only contain
|
||||||
|
* letters or digits.") straight to the admin.
|
||||||
|
*/
|
||||||
|
const USERNAME_REGEX = /^[a-zA-Z0-9\-._@+]+$/;
|
||||||
|
|
||||||
|
/** Minimum age (years) a player must be, mirroring the backend's [MinimumAge(15)]. */
|
||||||
|
const PLAYER_MINIMUM_AGE = 15;
|
||||||
|
|
||||||
|
/** True when `value` is a syntactically valid email address. */
|
||||||
|
export function isValidEmail(value: string): boolean {
|
||||||
|
return EMAIL_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `value` is a plausible Argentine phone number: only digits,
|
||||||
|
* spaces, `+`, `-`, and parentheses, and exactly 10 digits — the national
|
||||||
|
* format (area code + local number) used for calls placed from inside the
|
||||||
|
* country, with no leading `0` trunk prefix, no `15`, no `+54` country code
|
||||||
|
* and no `9` mobile marker (those only apply to international dialing,
|
||||||
|
* which this app — a local league — never needs).
|
||||||
|
*/
|
||||||
|
export function isValidPhone(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = trimmed.replace(/\D/g, '');
|
||||||
|
return digits.length === 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `value` is a plausible DNI/document number: 6 to 15 digits only. */
|
||||||
|
export function isValidDocumentNumber(value: string): boolean {
|
||||||
|
return DOCUMENT_NUMBER_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `value` is a valid username: letters, digits, and `-._@+` only
|
||||||
|
* (no spaces), matching the backend's actual accepted character set.
|
||||||
|
*/
|
||||||
|
export function isValidUsername(value: string): boolean {
|
||||||
|
return USERNAME_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `birthDate` (an `<input type="date">` value, "YYYY-MM-DD") puts
|
||||||
|
* the person at least {@link PLAYER_MINIMUM_AGE} years old today. Parses the
|
||||||
|
* Y/M/D components explicitly and builds a LOCAL date rather than relying on
|
||||||
|
* `new Date("YYYY-MM-DD")` — that form is UTC-midnight per spec, which would
|
||||||
|
* silently shift the effective date by a day in any timezone behind UTC (all
|
||||||
|
* of Argentina), misjudging someone born exactly on the cutoff date. An
|
||||||
|
* unparsable value is treated as invalid — the caller already requires the
|
||||||
|
* field, so an empty/malformed string should never reach here as "valid".
|
||||||
|
*/
|
||||||
|
export function isAtLeastMinimumPlayerAge(birthDate: string): boolean {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate.trim());
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
const parsed = new Date(Number(year), Number(month) - 1, Number(day));
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setHours(0, 0, 0, 0);
|
||||||
|
cutoff.setFullYear(cutoff.getFullYear() - PLAYER_MINIMUM_AGE);
|
||||||
|
|
||||||
|
return parsed.getTime() <= cutoff.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a phone number for display in the Argentine national shape, e.g.
|
||||||
|
* "3435551234" → "343 555-1234" — no "+54" country code and no "9" mobile
|
||||||
|
* marker, since this app is only ever dialed from inside the country. Only
|
||||||
|
* a 10-digit local number (area code + line, the shape every phone in this
|
||||||
|
* app is stored as) can be confidently split into area/exchange/line
|
||||||
|
* without an area-code length table, so anything else is returned
|
||||||
|
* unchanged rather than mangled.
|
||||||
|
*/
|
||||||
|
export function formatArgentinePhone(value: string): string {
|
||||||
|
const digits = value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (digits.length !== 10) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = digits.slice(0, 3);
|
||||||
|
const exchange = digits.slice(3, 6);
|
||||||
|
const line = digits.slice(6);
|
||||||
|
return `${area} ${exchange}-${line}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a DNI/document number with dot thousands-separators for display
|
||||||
|
* (e.g. "38742615" → "38.742.615", matching the printed-DNI convention).
|
||||||
|
* Non-numeric input (legacy/test data) is returned unchanged rather than
|
||||||
|
* mangled.
|
||||||
|
*/
|
||||||
|
export function formatDocumentNumber(value: string): string {
|
||||||
|
if (!isValidDocumentNumber(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(value).toLocaleString('es-AR');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spanish (voseo) helper/error messages shown under the fields. */
|
||||||
|
export const VALIDATION_MESSAGES = {
|
||||||
|
email: 'Ingresá un email válido',
|
||||||
|
phone: 'Ingresá un teléfono válido',
|
||||||
|
documentNumber: 'El documento debe tener solo números',
|
||||||
|
minimumPlayerAge: `El jugador debe tener al menos ${PLAYER_MINIMUM_AGE} años`,
|
||||||
|
username: 'El nombre de usuario no puede contener espacios ni símbolos (solo letras, números y - . _ @ +)',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { sendPost } from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
IDataSeedResult,
|
||||||
|
IDataWipeResult,
|
||||||
|
} from '@/modules/dataMaintenance/type/dataMaintenance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only tools for resetting tournament-domain data to a clean,
|
||||||
|
* realistic sample state.
|
||||||
|
*/
|
||||||
|
export const dataMaintenanceService = {
|
||||||
|
/**
|
||||||
|
* Deletes every tournament-domain row. Identity is untouched.
|
||||||
|
* @returns {Promise<AxiosResponse<IDataWipeResult>>} Row counts removed.
|
||||||
|
*/
|
||||||
|
wipeSampleData: async (): Promise<AxiosResponse<IDataWipeResult>> =>
|
||||||
|
await sendPost(`${routes.dataMaintenance}/wipe`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds 2 complete sample tournaments. Rejects with a 409 response if
|
||||||
|
* the database already has tournament data.
|
||||||
|
* @returns {Promise<AxiosResponse<IDataSeedResult>>} Row counts created.
|
||||||
|
*/
|
||||||
|
seedSampleData: async (): Promise<AxiosResponse<IDataSeedResult>> =>
|
||||||
|
await sendPost(`${routes.dataMaintenance}/seed`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface IDataWipeResult {
|
||||||
|
tournaments: number;
|
||||||
|
divisions: number;
|
||||||
|
teams: number;
|
||||||
|
players: number;
|
||||||
|
matches: number;
|
||||||
|
matchSeries: number;
|
||||||
|
playerSanctions: number;
|
||||||
|
playerStatistics: number;
|
||||||
|
scorers: number;
|
||||||
|
stageTeamMatches: number;
|
||||||
|
playerTeamRegistrations: number;
|
||||||
|
stages: number;
|
||||||
|
venues: number;
|
||||||
|
blogPosts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDataSeedResult {
|
||||||
|
tournaments: number;
|
||||||
|
divisions: number;
|
||||||
|
teams: number;
|
||||||
|
players: number;
|
||||||
|
matches: number;
|
||||||
|
playerSanctions: number;
|
||||||
|
blogPosts: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { ErrorProvider } from '@/modules/error/context/error.context';
|
||||||
|
import { DivisionProvider } from '@/modules/division/context/division.context';
|
||||||
|
import { useDivision } from '@/modules/division/hook/division.hook';
|
||||||
|
import { divisionService } from '@/modules/division/service/division.service';
|
||||||
|
import type { GUID } from '@/modules/core/types/types';
|
||||||
|
|
||||||
|
vi.mock('@/modules/division/service/division.service');
|
||||||
|
vi.mock('sweetalert2', () => ({
|
||||||
|
default: {
|
||||||
|
fire: vi.fn(),
|
||||||
|
getContainer: vi.fn().mockReturnValue(null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedPutDivisionById = vi.mocked(divisionService.putDivisionById);
|
||||||
|
const mockedDeleteDivisionsById = vi.mocked(divisionService.deleteDivisionsById);
|
||||||
|
const mockedSwalFire = vi.mocked(Swal.fire);
|
||||||
|
|
||||||
|
const DIVISION_ID = '55555555-5555-5555-5555-555555555555' as GUID;
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ErrorProvider>
|
||||||
|
<DivisionProvider>{children}</DivisionProvider>
|
||||||
|
</ErrorProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DivisionProvider — no duplicate success toast', () => {
|
||||||
|
/**
|
||||||
|
* divisionEditPage.tsx / divisionsPage.tsx already show their own
|
||||||
|
* confirmation for these actions. The context used to ALSO fire a toast, so
|
||||||
|
* the user saw two modals with the same message for one action.
|
||||||
|
*/
|
||||||
|
it('does not fire its own toast after putDivisionById succeeds (200)', async () => {
|
||||||
|
mockedPutDivisionById.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: { id: DIVISION_ID, name: 'Zona B' },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDivision(), { wrapper });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.putDivisionById(DIVISION_ID, {
|
||||||
|
name: 'Zona B',
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedSwalFire).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire its own toast after putDivisionById succeeds (204)', async () => {
|
||||||
|
mockedPutDivisionById.mockResolvedValueOnce({ status: 204 } as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDivision(), { wrapper });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.putDivisionById(DIVISION_ID, {
|
||||||
|
name: 'Zona B',
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedSwalFire).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire its own toast after deleteDivisionsById succeeds', async () => {
|
||||||
|
mockedDeleteDivisionsById.mockResolvedValueOnce({ status: 204 } as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDivision(), { wrapper });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteDivisionsById(DIVISION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedSwalFire).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FetchOptions,
|
||||||
|
GenericResponsePagination,
|
||||||
|
GUID,
|
||||||
|
} from '@/modules/core/types/types';
|
||||||
|
import { useError } from '@/modules/error/hooks/error.hock';
|
||||||
|
import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler';
|
||||||
|
import { divisionService } from '@/modules/division/service/division.service';
|
||||||
|
import {
|
||||||
|
AddDivisionRequest,
|
||||||
|
DivisionFiltered,
|
||||||
|
IDivisionResponse,
|
||||||
|
IDivisionContextProps,
|
||||||
|
IPutDivisionRequest,
|
||||||
|
} from '@/modules/division/type/division';
|
||||||
|
import { ITeamResponse } from '@/modules/team/type/team.d';
|
||||||
|
import { upsertListById } from '@/modules/core/utils/synchronizeStates';
|
||||||
|
import { divisionKeys } from '@/modules/division/queryKeys';
|
||||||
|
import { HttpStatus } from '@/modules/core/constants/httpStatus';
|
||||||
|
|
||||||
|
export const DivisionContext = createContext<IDivisionContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DivisionProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [division, setDivision] = useState<IDivisionResponse | null>(null);
|
||||||
|
const [divisions, setDivisions] = useState<IDivisionResponse[] | null>(null);
|
||||||
|
|
||||||
|
const { setMessage } = useError();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleUnknownError = useUnknownErrorHandler();
|
||||||
|
|
||||||
|
const addDivisionMutation = useMutation({
|
||||||
|
mutationFn: divisionService.addDivision,
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateFixtureMutation = useMutation({
|
||||||
|
mutationFn: divisionService.generateFixtureByDivisionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const putDivisionMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
divisionRequest,
|
||||||
|
}: {
|
||||||
|
id: GUID;
|
||||||
|
divisionRequest: IPutDivisionRequest;
|
||||||
|
}) => divisionService.putDivisionById(id, divisionRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDivisionMutation = useMutation({
|
||||||
|
mutationFn: divisionService.deleteDivisionsById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrollTeamsMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
divisionId,
|
||||||
|
teamIds,
|
||||||
|
}: {
|
||||||
|
divisionId: GUID;
|
||||||
|
teamIds: GUID[];
|
||||||
|
}) => divisionService.enrollTeams(divisionId, teamIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unenrollTeamsMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
divisionId,
|
||||||
|
teamIds,
|
||||||
|
}: {
|
||||||
|
divisionId: GUID;
|
||||||
|
teamIds: GUID[];
|
||||||
|
}) => divisionService.unenrollTeams(divisionId, teamIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoDistributeMutation = useMutation({
|
||||||
|
mutationFn: divisionService.autoDistribute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rebuildSubGroupsMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
divisionId,
|
||||||
|
subGroupCount,
|
||||||
|
}: {
|
||||||
|
divisionId: GUID;
|
||||||
|
subGroupCount: number;
|
||||||
|
}) => divisionService.rebuildSubGroups(divisionId, subGroupCount),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reassignTeamToSubGroupMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
divisionId,
|
||||||
|
teamId,
|
||||||
|
fromStageId,
|
||||||
|
toStageId,
|
||||||
|
}: {
|
||||||
|
divisionId: GUID;
|
||||||
|
teamId: GUID;
|
||||||
|
fromStageId: GUID;
|
||||||
|
toStageId: GUID;
|
||||||
|
}) =>
|
||||||
|
divisionService.reassignTeamToSubGroup(
|
||||||
|
divisionId,
|
||||||
|
teamId,
|
||||||
|
fromStageId,
|
||||||
|
toStageId
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!division) return;
|
||||||
|
setDivisions(prev => upsertListById(prev, division));
|
||||||
|
}, [division]);
|
||||||
|
|
||||||
|
const addDivision = useCallback(
|
||||||
|
async (
|
||||||
|
divisionRequest: AddDivisionRequest
|
||||||
|
): Promise<IDivisionResponse | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IDivisionResponse> =
|
||||||
|
await addDivisionMutation.mutateAsync(divisionRequest);
|
||||||
|
if (res && res.data) {
|
||||||
|
setDivision(res.data);
|
||||||
|
queryClient.setQueryData(divisionKeys.byId(res.data.id), res);
|
||||||
|
setMessage(res.status, ['Division creada exitosamente']);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.list(),
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addDivisionMutation, queryClient, setMessage, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateFixtureByDivisionId = useCallback(
|
||||||
|
async (id: GUID): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await generateFixtureMutation.mutateAsync(id);
|
||||||
|
setMessage(HttpStatus.Ok, ['Fixture generado exitosamente']);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[generateFixtureMutation, setMessage, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const putDivisionById = useCallback(
|
||||||
|
async (
|
||||||
|
id: GUID,
|
||||||
|
divisionRequest: IPutDivisionRequest
|
||||||
|
): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<IDivisionResponse> =
|
||||||
|
await putDivisionMutation.mutateAsync({ id, divisionRequest });
|
||||||
|
|
||||||
|
// Success feedback belongs to the calling page (divisionEditPage.tsx
|
||||||
|
// shows its own confirmation) — a toast here too means two modals.
|
||||||
|
if (res && res.status === HttpStatus.NoContent) {
|
||||||
|
setDivision(prev => {
|
||||||
|
if (!prev || prev.id !== id) return prev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
name: divisionRequest.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.list(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else if (res && res.data) {
|
||||||
|
setDivision(res.data);
|
||||||
|
queryClient.setQueryData(divisionKeys.byId(id), res);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.list(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[putDivisionMutation, setDivision, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDivisionsById = useCallback(
|
||||||
|
async (idOrSlug: string): Promise<IDivisionResponse | void> => {
|
||||||
|
try {
|
||||||
|
// Always fetch the full `/detail` projection. The cached list version
|
||||||
|
// (from getDivisionsByFilters) is a lighter shape without positions,
|
||||||
|
// group standings or qualificationRanges, so short-circuiting to it
|
||||||
|
// left the admin detail view's standings uncoloured (HU-45) — unlike
|
||||||
|
// the public panel, which calls the service directly and always hits
|
||||||
|
// `/detail`.
|
||||||
|
const res: AxiosResponse<IDivisionResponse> =
|
||||||
|
await queryClient.fetchQuery({
|
||||||
|
queryKey: divisionKeys.byId(idOrSlug),
|
||||||
|
queryFn: async () =>
|
||||||
|
await divisionService.getDivisionsById(idOrSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data) {
|
||||||
|
setDivision(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDivision, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDivisionsByFilters = useCallback(
|
||||||
|
async (
|
||||||
|
filter: DivisionFiltered,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<GenericResponsePagination<IDivisionResponse> | void> => {
|
||||||
|
try {
|
||||||
|
const res = await queryClient.fetchQuery({
|
||||||
|
queryKey: divisionKeys.list(filter),
|
||||||
|
queryFn: async () =>
|
||||||
|
await divisionService.getDivisionsByFilters(filter),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.data?.items) {
|
||||||
|
setDivisions(res.data.items);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!options?.silent) handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDivisions, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteDivisionsById = useCallback(
|
||||||
|
async (id: GUID): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await deleteDivisionMutation.mutateAsync(id);
|
||||||
|
setDivision(null);
|
||||||
|
setDivisions(prev => (prev ? prev.filter(e => e.id !== id) : null));
|
||||||
|
queryClient.removeQueries({ queryKey: divisionKeys.byId(id) });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: divisionKeys.list() });
|
||||||
|
// Success feedback belongs to the calling page (divisionsPage.tsx shows
|
||||||
|
// its own "¡Eliminada!" confirmation) — a toast here too means two modals.
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteDivisionMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRoster = useCallback(
|
||||||
|
async (divisionId: GUID): Promise<ITeamResponse[] | void> => {
|
||||||
|
try {
|
||||||
|
const res = await queryClient.fetchQuery({
|
||||||
|
queryKey: divisionKeys.roster(divisionId),
|
||||||
|
queryFn: async () => await divisionService.getRoster(divisionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.data) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrollTeams = useCallback(
|
||||||
|
async (divisionId: GUID, teamIds: GUID[]): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
await enrollTeamsMutation.mutateAsync({ divisionId, teamIds });
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.roster(divisionId),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enrollTeamsMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unenrollTeams = useCallback(
|
||||||
|
async (divisionId: GUID, teamIds: GUID[]): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
await unenrollTeamsMutation.mutateAsync({ divisionId, teamIds });
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.roster(divisionId),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unenrollTeamsMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoDistribute = useCallback(
|
||||||
|
async (divisionId: GUID): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
await autoDistributeMutation.mutateAsync(divisionId);
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoDistributeMutation, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rebuildSubGroups = useCallback(
|
||||||
|
async (divisionId: GUID, subGroupCount: number): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
await rebuildSubGroupsMutation.mutateAsync({ divisionId, subGroupCount });
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.roster(divisionId),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rebuildSubGroupsMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reassignTeamToSubGroup = useCallback(
|
||||||
|
async (
|
||||||
|
divisionId: GUID,
|
||||||
|
teamId: GUID,
|
||||||
|
fromStageId: GUID,
|
||||||
|
toStageId: GUID
|
||||||
|
): Promise<boolean | void> => {
|
||||||
|
try {
|
||||||
|
await reassignTeamToSubGroupMutation.mutateAsync({
|
||||||
|
divisionId,
|
||||||
|
teamId,
|
||||||
|
fromStageId,
|
||||||
|
toStageId,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: divisionKeys.roster(divisionId),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleUnknownError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reassignTeamToSubGroupMutation, queryClient, handleUnknownError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const container: IDivisionContextProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
division,
|
||||||
|
divisions,
|
||||||
|
addDivision,
|
||||||
|
generateFixtureByDivisionId,
|
||||||
|
putDivisionById,
|
||||||
|
getDivisionsByFilters,
|
||||||
|
getDivisionsById,
|
||||||
|
deleteDivisionsById,
|
||||||
|
getRoster,
|
||||||
|
enrollTeams,
|
||||||
|
unenrollTeams,
|
||||||
|
autoDistribute,
|
||||||
|
rebuildSubGroups,
|
||||||
|
reassignTeamToSubGroup,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
division,
|
||||||
|
divisions,
|
||||||
|
addDivision,
|
||||||
|
generateFixtureByDivisionId,
|
||||||
|
putDivisionById,
|
||||||
|
getDivisionsByFilters,
|
||||||
|
getDivisionsById,
|
||||||
|
deleteDivisionsById,
|
||||||
|
getRoster,
|
||||||
|
enrollTeams,
|
||||||
|
unenrollTeams,
|
||||||
|
autoDistribute,
|
||||||
|
rebuildSubGroups,
|
||||||
|
reassignTeamToSubGroup,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DivisionContext.Provider value={container}>
|
||||||
|
{children}
|
||||||
|
</DivisionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { DivisionContext } from '@/modules/division/context/division.context';
|
||||||
|
|
||||||
|
export const useDivision = () => {
|
||||||
|
const context = useContext(DivisionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDivision must be used within a DivisionProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { divisionKeys } from './queryKeys';
|
||||||
|
import { GUID } from '@/modules/core/types/types';
|
||||||
|
import { DivisionFiltered } from '@/modules/division/type/division';
|
||||||
|
|
||||||
|
describe('divisionKeys', () => {
|
||||||
|
const id: GUID = '88888888-8888-8888-8888-888888888888';
|
||||||
|
|
||||||
|
it('list() returns the bare list literal with no trailing undefined', () => {
|
||||||
|
expect(divisionKeys.list()).toEqual(['division', 'list']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list(filter) returns the filtered list literal', () => {
|
||||||
|
const filter: DivisionFiltered = { pageNumber: 1 };
|
||||||
|
expect(divisionKeys.list(filter)).toEqual(['division', 'list', filter]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('byId(id) returns the by-id literal', () => {
|
||||||
|
expect(divisionKeys.byId(id)).toEqual(['division', 'byId', id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { DivisionFiltered } from '@/modules/division/type/division';
|
||||||
|
|
||||||
|
export const divisionKeys = {
|
||||||
|
list: (filter?: DivisionFiltered) =>
|
||||||
|
filter === undefined
|
||||||
|
? (['division', 'list'] as const)
|
||||||
|
: (['division', 'list', filter] as const),
|
||||||
|
byId: (idOrSlug: string) => ['division', 'byId', idOrSlug] as const,
|
||||||
|
roster: (divisionId: string) => ['division', 'roster', divisionId] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import routes from '@/modules/core/constants/routes';
|
||||||
|
import { withTablePageSize } from '@/modules/core/constants/pagination';
|
||||||
|
import { GenericResponsePagination, GUID } from '@/modules/core/types/types';
|
||||||
|
import {
|
||||||
|
sendDelete,
|
||||||
|
sendGet,
|
||||||
|
sendPost,
|
||||||
|
sendPut,
|
||||||
|
} from '@/modules/core/utils/axiosUtils';
|
||||||
|
import {
|
||||||
|
AddDivisionRequest,
|
||||||
|
DivisionFiltered,
|
||||||
|
EnrollTeamsRequest,
|
||||||
|
IDivisionResponse,
|
||||||
|
IPutDivisionRequest,
|
||||||
|
ReassignTeamToSubGroupRequest,
|
||||||
|
RebuildSubGroupsRequest,
|
||||||
|
UnenrollTeamsRequest,
|
||||||
|
} from '@/modules/division/type/division';
|
||||||
|
import { ITeamResponse } from '@/modules/team/type/team.d';
|
||||||
|
import { IStageResponse } from '@/modules/stage/type/stage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DivisionService provides methods to interact with the divisions API.
|
||||||
|
*/
|
||||||
|
export const divisionService = {
|
||||||
|
/**
|
||||||
|
* Adds a new division.
|
||||||
|
* @param {AddDivisionRequest} division - The division data to be added.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
addDivision: async (
|
||||||
|
division: AddDivisionRequest
|
||||||
|
): Promise<AxiosResponse<IDivisionResponse>> =>
|
||||||
|
sendPost<IDivisionResponse>(routes.divisions, division),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the fixture for a division based on its ID.
|
||||||
|
* @param {string} id - The ID of the division to generate the fixture for.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
generateFixtureByDivisionId: async (id: GUID): Promise<AxiosResponse<void>> =>
|
||||||
|
sendPost<void>(`${routes.divisions}/${id}/generate-fixture`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing division by its ID.
|
||||||
|
* @param {string} id - The ID of the division to be updated.
|
||||||
|
* @param {IPutDivisionRequest} division - The updated division data.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the server response.
|
||||||
|
*/
|
||||||
|
putDivisionById: async (
|
||||||
|
id: GUID,
|
||||||
|
division: IPutDivisionRequest
|
||||||
|
): Promise<AxiosResponse<IDivisionResponse>> =>
|
||||||
|
sendPut<IDivisionResponse>(`${routes.divisions}/${id}`, division),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a division by its ID or its public slug.
|
||||||
|
* @param {string} idOrSlug - The ID or slug of the division to retrieve.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with the division data.
|
||||||
|
*/
|
||||||
|
getDivisionsById: async (
|
||||||
|
idOrSlug: string
|
||||||
|
): Promise<AxiosResponse<IDivisionResponse>> =>
|
||||||
|
sendGet<IDivisionResponse>(`${routes.divisions}/${idOrSlug}/detail`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves divisions based on provided filters.
|
||||||
|
* @param {DivisionFiltered} filter - The filters to apply when retrieving divisions.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves with a list of divisions matching the filter.
|
||||||
|
*/
|
||||||
|
getDivisionsByFilters: async (
|
||||||
|
filter: DivisionFiltered
|
||||||
|
): Promise<AxiosResponse<GenericResponsePagination<IDivisionResponse>>> =>
|
||||||
|
sendGet<GenericResponsePagination<IDivisionResponse>>(
|
||||||
|
routes.divisions,
|
||||||
|
withTablePageSize(filter)
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a division by its ID.
|
||||||
|
* @param {string} id - The ID of the division to delete.
|
||||||
|
* @returns {Promise<AxiosResponse<IDivisionResponse>>} - A promise that resolves when the division is deleted.
|
||||||
|
*/
|
||||||
|
deleteDivisionsById: async (id: GUID): Promise<AxiosResponse<void>> =>
|
||||||
|
sendDelete<void>(`${routes.divisions}/${id}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches every team enrolled in a division's roster.
|
||||||
|
* @param {GUID} divisionId - The division whose roster to fetch.
|
||||||
|
* @returns {Promise<AxiosResponse<ITeamResponse[]>>} - The enrolled teams.
|
||||||
|
*/
|
||||||
|
getRoster: async (divisionId: GUID): Promise<AxiosResponse<ITeamResponse[]>> =>
|
||||||
|
sendGet<ITeamResponse[]>(`${routes.divisions}/${divisionId}/roster`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrols one or more teams in a division's roster.
|
||||||
|
* @param {GUID} divisionId - The division to enrol the teams into.
|
||||||
|
* @param {GUID[]} teamIds - The teams to enrol.
|
||||||
|
* @returns {Promise<AxiosResponse<ITeamResponse[]>>} - The division's roster as it stands after enrolling (200).
|
||||||
|
*/
|
||||||
|
enrollTeams: async (
|
||||||
|
divisionId: GUID,
|
||||||
|
teamIds: GUID[]
|
||||||
|
): Promise<AxiosResponse<ITeamResponse[]>> =>
|
||||||
|
sendPost<ITeamResponse[]>(`${routes.divisions}/${divisionId}/roster`, {
|
||||||
|
teamIds,
|
||||||
|
} satisfies EnrollTeamsRequest),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one or more teams from a division's roster, cascading to any
|
||||||
|
* stage placement they still hold within the division.
|
||||||
|
* @param {GUID} divisionId - The division to unenrol the teams from.
|
||||||
|
* @param {GUID[]} teamIds - The teams to unenrol.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} - The response confirming removal.
|
||||||
|
*/
|
||||||
|
unenrollTeams: async (
|
||||||
|
divisionId: GUID,
|
||||||
|
teamIds: GUID[]
|
||||||
|
): Promise<AxiosResponse<void>> =>
|
||||||
|
sendDelete<void>(`${routes.divisions}/${divisionId}/roster`, undefined, {
|
||||||
|
teamIds,
|
||||||
|
} satisfies UnenrollTeamsRequest),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the division's current sub-group placements and re-runs a
|
||||||
|
* balanced random distribution over its whole roster.
|
||||||
|
* @param {GUID} divisionId - The division whose sub-groups to auto-distribute.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} - The response confirming the redistribution.
|
||||||
|
*/
|
||||||
|
autoDistribute: async (divisionId: GUID): Promise<AxiosResponse<void>> =>
|
||||||
|
sendPost<void>(`${routes.divisions}/${divisionId}/roster/auto-distribute`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds a division's sub-group stage layer to a new count, keeping the
|
||||||
|
* roster untouched.
|
||||||
|
* @param {GUID} divisionId - The division whose sub-group count to change.
|
||||||
|
* @param {number} subGroupCount - The new sub-group count.
|
||||||
|
* @returns {Promise<AxiosResponse<IStageResponse[]>>} - The newly-built sub-group stages (200).
|
||||||
|
*/
|
||||||
|
rebuildSubGroups: async (
|
||||||
|
divisionId: GUID,
|
||||||
|
subGroupCount: number
|
||||||
|
): Promise<AxiosResponse<IStageResponse[]>> =>
|
||||||
|
sendPost<IStageResponse[]>(`${routes.divisions}/${divisionId}/sub-groups/rebuild`, {
|
||||||
|
subGroupCount,
|
||||||
|
} satisfies RebuildSubGroupsRequest),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually moves one enrolled team from one sub-group to another within
|
||||||
|
* the same division, without touching any other team's placement.
|
||||||
|
* @param {GUID} divisionId - The division the two sub-groups belong to.
|
||||||
|
* @param {GUID} teamId - The team to move.
|
||||||
|
* @param {GUID} fromStageId - The sub-group stage the team currently belongs to.
|
||||||
|
* @param {GUID} toStageId - The sub-group stage to move the team into.
|
||||||
|
* @returns {Promise<AxiosResponse<void>>} - The response confirming the move.
|
||||||
|
*/
|
||||||
|
reassignTeamToSubGroup: async (
|
||||||
|
divisionId: GUID,
|
||||||
|
teamId: GUID,
|
||||||
|
fromStageId: GUID,
|
||||||
|
toStageId: GUID
|
||||||
|
): Promise<AxiosResponse<void>> =>
|
||||||
|
sendPost<void>(`${routes.divisions}/${divisionId}/sub-groups/reassign`, {
|
||||||
|
teamId,
|
||||||
|
fromStageId,
|
||||||
|
toStageId,
|
||||||
|
} satisfies ReassignTeamToSubGroupRequest),
|
||||||
|
};
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import {
|
||||||
|
FetchOptions,
|
||||||
|
Filtered,
|
||||||
|
GenericResponsePagination,
|
||||||
|
GUID,
|
||||||
|
} from '@/modules/core/types/types';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { ITeamResponse } from '@/modules/team/type/team.d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context properties and methods for managing divisions in a React application.
|
||||||
|
* These methods interact with the backend for creating, updating, fetching, and deleting divisions.
|
||||||
|
* @interface IDivisionContextProps
|
||||||
|
*/
|
||||||
|
export interface IDivisionContextProps {
|
||||||
|
division: IDivisionResponse | null;
|
||||||
|
divisions: IDivisionResponse[] | null;
|
||||||
|
/**
|
||||||
|
* Adds a new division to the system.
|
||||||
|
* @param division The details of the division to add.
|
||||||
|
* @returns A promise that resolves with the response containing the newly added division.
|
||||||
|
*/
|
||||||
|
addDivision(division: AddDivisionRequest): Promise<IDivisionResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates fixtures for a division based on its ID.
|
||||||
|
* @param id The ID of the division for which to generate fixtures.
|
||||||
|
* @returns A promise that resolves when the fixtures are successfully generated.
|
||||||
|
*/
|
||||||
|
generateFixtureByDivisionId(id: GUID): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing division by its ID.
|
||||||
|
* @param id The ID of the division to update.
|
||||||
|
* @param division The updated division data.
|
||||||
|
* @returns A promise that resolves with the response containing the updated division.
|
||||||
|
*/
|
||||||
|
putDivisionById(
|
||||||
|
id: GUID,
|
||||||
|
division: IPutDivisionRequest
|
||||||
|
): Promise<boolean | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a division by its ID or its public slug.
|
||||||
|
* @param idOrSlug The ID or slug of the division to fetch.
|
||||||
|
* @returns A promise that resolves with the division data.
|
||||||
|
*/
|
||||||
|
getDivisionsById(idOrSlug: string): Promise<IDivisionResponse | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches divisions based on filters and pagination.
|
||||||
|
* @param filter The filter criteria to apply when fetching divisions.
|
||||||
|
* @returns A promise that resolves with a paginated response containing filtered divisions.
|
||||||
|
*/
|
||||||
|
getDivisionsByFilters(
|
||||||
|
filter: DivisionFiltered,
|
||||||
|
options?: FetchOptions
|
||||||
|
): Promise<GenericResponsePagination<IDivisionResponse> | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a division by its ID.
|
||||||
|
* @param id The ID of the division to delete.
|
||||||
|
* @returns A promise resolving to `true` if the division was deleted,
|
||||||
|
* `false` if the request failed (the global error is already reported
|
||||||
|
* either way).
|
||||||
|
*/
|
||||||
|
deleteDivisionsById(id: GUID): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches every team currently enrolled in a division's roster
|
||||||
|
* (`DivisionTeamRegistration`), independent of any stage placement. The
|
||||||
|
* authoritative source of "who is in this division" — including a
|
||||||
|
* playoffs-only division with no group stage.
|
||||||
|
* @param divisionId The division whose roster to fetch.
|
||||||
|
* @returns A promise resolving to the enrolled teams, or void on failure.
|
||||||
|
*/
|
||||||
|
getRoster(divisionId: GUID): Promise<ITeamResponse[] | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrols one or more teams in a division's roster. Rejected (409) when a
|
||||||
|
* team already holds a registration in another regular division of the
|
||||||
|
* same tournament, or the tournament structure is locked.
|
||||||
|
* @param divisionId The division to enrol the teams into.
|
||||||
|
* @param teamIds The teams to enrol.
|
||||||
|
* @returns A promise resolving to true on success, or void on failure.
|
||||||
|
*/
|
||||||
|
enrollTeams(divisionId: GUID, teamIds: GUID[]): Promise<boolean | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one or more teams from a division's roster. Cascades: any
|
||||||
|
* `StageTeamMatch` the team still holds within the division's stages is
|
||||||
|
* removed in the same operation.
|
||||||
|
* @param divisionId The division to unenrol the teams from.
|
||||||
|
* @param teamIds The teams to unenrol.
|
||||||
|
* @returns A promise resolving to true on success, or void on failure.
|
||||||
|
*/
|
||||||
|
unenrollTeams(divisionId: GUID, teamIds: GUID[]): Promise<boolean | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the division's current sub-group placements and re-runs a
|
||||||
|
* balanced random distribution over its whole roster (HU-122). Always
|
||||||
|
* balanced, not fill-only-empties.
|
||||||
|
* @param divisionId The division whose sub-groups to auto-distribute.
|
||||||
|
* @returns A promise resolving to true on success, or void on failure.
|
||||||
|
*/
|
||||||
|
autoDistribute(divisionId: GUID): Promise<boolean | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds a division's sub-group stage layer to a new count, keeping the
|
||||||
|
* roster untouched, and re-runs the balanced distribution over it (HU-123).
|
||||||
|
* @param divisionId The division whose sub-group count to change.
|
||||||
|
* @param subGroupCount The new sub-group count.
|
||||||
|
* @returns A promise resolving to true on success, or void on failure.
|
||||||
|
*/
|
||||||
|
rebuildSubGroups(divisionId: GUID, subGroupCount: number): Promise<boolean | void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually moves one enrolled team from one sub-group to another within the
|
||||||
|
* same division (HU-122), without touching any other team's placement.
|
||||||
|
* Rejected (409) when the move would drop the source sub-group below the
|
||||||
|
* minimum size, the team is not currently placed in `fromStageId`, or the
|
||||||
|
* two stages belong to different divisions.
|
||||||
|
* @param divisionId The division the two sub-groups belong to.
|
||||||
|
* @param teamId The team to move.
|
||||||
|
* @param fromStageId The sub-group stage the team currently belongs to.
|
||||||
|
* @param toStageId The sub-group stage to move the team into.
|
||||||
|
* @returns A promise resolving to true on success, or void on failure.
|
||||||
|
*/
|
||||||
|
reassignTeamToSubGroup(
|
||||||
|
divisionId: GUID,
|
||||||
|
teamId: GUID,
|
||||||
|
fromStageId: GUID,
|
||||||
|
toStageId: GUID
|
||||||
|
): Promise<boolean | void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One position-range → playoff-destination entry (HU-45) sent with a
|
||||||
|
* division so the backend can seed multiple cups from the final table
|
||||||
|
* (HU-81). Field names mirror the backend `PlayoffMappingRequest` DTO.
|
||||||
|
* @interface PlayoffMappingRequest
|
||||||
|
*/
|
||||||
|
export interface PlayoffMappingRequest {
|
||||||
|
/** First standings position in the range (1-based, inclusive). */
|
||||||
|
fromPosition: number;
|
||||||
|
|
||||||
|
/** Last standings position in the range (1-based, inclusive). */
|
||||||
|
toPosition: number;
|
||||||
|
|
||||||
|
/** The destination cup's BracketName (e.g. "Copa Oro"). */
|
||||||
|
destination: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for adding a new division.
|
||||||
|
* @interface AddDivisionRequest
|
||||||
|
*/
|
||||||
|
export interface AddDivisionRequest {
|
||||||
|
/**
|
||||||
|
* The name of the division.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the tournament to which the division belongs.
|
||||||
|
* @type {GUID}
|
||||||
|
*/
|
||||||
|
tournamentId: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks this division as a cross-division cup that intentionally draws
|
||||||
|
* teams from every other division in the tournament (e.g. an
|
||||||
|
* admin-named "Copa Club12"), exempt from the "one team, one division"
|
||||||
|
* rule. Defaults to false.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isCrossDivisionCup?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a cross-division cup (HU-110): how many teams advance from each of
|
||||||
|
* the cup's group stages into the pooled knockout bracket. Only meaningful
|
||||||
|
* when `isCrossDivisionCup` is true; the backend auto-sizes the bracket's
|
||||||
|
* first round from the pooled top-`qualifiersPerGroup` of every group.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
qualifiersPerGroup?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points awarded for a win in this division's standings (HU-79).
|
||||||
|
* Omit to let the backend default to 2.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
pointsForWin?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points awarded for a loss in this division's standings (HU-79).
|
||||||
|
* Omit to let the backend default to 1.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
pointsForLoss?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Competitive category (gender) of the division (HU-48). MUST match the
|
||||||
|
* parent tournament's category — the backend rejects a division whose
|
||||||
|
* category differs from its tournament, and `Division.Category` defaults to
|
||||||
|
* Masculine server-side. The wizard therefore sends the tournament's
|
||||||
|
* category on every division so a Feminine tournament's zones are created
|
||||||
|
* as Feminine and not rejected.
|
||||||
|
* @type {TournamentCategory}
|
||||||
|
*/
|
||||||
|
category?: TournamentCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional position-range → playoff-destination mappings (HU-45) the
|
||||||
|
* wizard sends so the backend can seed multiple cups (HU-81). Ranges
|
||||||
|
* must not overlap.
|
||||||
|
* @type {PlayoffMappingRequest[]}
|
||||||
|
*/
|
||||||
|
playoffMappings?: PlayoffMappingRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One standings-position range that qualifies to a playoff cup (HU-45),
|
||||||
|
* shaped for the public standings table so it can highlight the qualifying
|
||||||
|
* rows and render a per-cup legend. Mirrors the backend
|
||||||
|
* `QualificationRangeResponse` DTO.
|
||||||
|
* @interface QualificationRange
|
||||||
|
*/
|
||||||
|
export interface QualificationRange {
|
||||||
|
/** First standings position in the range (1-based, inclusive). */
|
||||||
|
fromPosition: number;
|
||||||
|
|
||||||
|
/** Last standings position in the range (1-based, inclusive). */
|
||||||
|
toPosition: number;
|
||||||
|
|
||||||
|
/** The cup the teams in this range qualify for (e.g. "Copa Oro"). */
|
||||||
|
cupName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cup's rank, top-down: 0 is the top cup ("Copa Oro"), 1 the next, and
|
||||||
|
* so on. Drives the color painted on each qualifying row.
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response structure for a division, including details about the division, its matches, and positions.
|
||||||
|
* @interface IDivisionResponse
|
||||||
|
*/
|
||||||
|
export interface IDivisionResponse {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the division.
|
||||||
|
* @type {GUID}
|
||||||
|
*/
|
||||||
|
id: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the division.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique, URL-friendly identifier used in public division links.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the division has finished.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isFinished: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of positions for teams in the division. For a multi-group
|
||||||
|
* cross-division cup this is the pooled union across every internal group
|
||||||
|
* (so a team counter reflects all groups); use `groupStandings` to render
|
||||||
|
* one table per group.
|
||||||
|
* @type {Position[]}
|
||||||
|
*/
|
||||||
|
positions?: Position[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One standings table per Group stage (HU-110). A regular zone has a single
|
||||||
|
* entry; a multi-group cross-division cup has one per internal group
|
||||||
|
* ("Grupo 1".."Grupo N"). Absent/empty when the division has no group stage.
|
||||||
|
* @type {GroupStandings[]}
|
||||||
|
*/
|
||||||
|
groupStandings?: GroupStandings[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the tournament to which the division belongs.
|
||||||
|
* @type {GUID}
|
||||||
|
*/
|
||||||
|
tournamentId: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parent tournament's slug, when it was resolved by the backend; null
|
||||||
|
* otherwise. Prefer this over `tournamentId` when building a link back to
|
||||||
|
* the tournament, so the URL never shows a raw UUID.
|
||||||
|
* @type {string | null}
|
||||||
|
*/
|
||||||
|
tournamentSlug?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this division is a cross-division cup (exempt from the "one
|
||||||
|
* team, one division" rule).
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isCrossDivisionCup: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a cross-division cup (HU-110): how many teams advance from EACH of
|
||||||
|
* the cup's internal groups into the pooled knockout bracket. Meaningless
|
||||||
|
* (defaults to 1) outside a cross-division cup.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
qualifiersPerGroup?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Competitive category (gender) of the division — matches its tournament.
|
||||||
|
* Used to tell apart same-named zones across masculine/feminine tournaments.
|
||||||
|
* @type {TournamentCategory}
|
||||||
|
*/
|
||||||
|
category?: TournamentCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The standings-position ranges that qualify to a playoff cup (HU-45),
|
||||||
|
* ordered top-down (order 0 = top cup). Lets the public standings table
|
||||||
|
* highlight the qualifying rows and render a per-cup legend. Absent/empty
|
||||||
|
* when the division has no playoff mappings.
|
||||||
|
* @type {QualificationRange[]}
|
||||||
|
*/
|
||||||
|
qualificationRanges?: QualificationRange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standings for a single Group stage within a division. A regular zone has
|
||||||
|
* exactly one; a multi-group cross-division cup (HU-110) has one per internal
|
||||||
|
* group, each computed only over that group's own matches.
|
||||||
|
* @type GroupStandings
|
||||||
|
*/
|
||||||
|
export type GroupStandings = {
|
||||||
|
/** The id of the Group stage these standings belong to. */
|
||||||
|
stageId: GUID;
|
||||||
|
|
||||||
|
/** The Group stage's name, used as the table label (e.g. "Grupo 1"). */
|
||||||
|
stageName: string;
|
||||||
|
|
||||||
|
/** The ordered standings for the teams in this group. */
|
||||||
|
positions: Position[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The structure for a position in a division, including team statistics.
|
||||||
|
* @type Position
|
||||||
|
*/
|
||||||
|
export type Position = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the team.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
teamId: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the team.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
teamName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the team's logo.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
logoUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of matches the team has played.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
matchesPlayed: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of matches the team has won.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
wins: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of matches the team has lost.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
losses: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of points the team has scored.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
pointsFor: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of points scored against the team.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
pointsAgainst: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difference between points scored and points against.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
pointsDifference: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total points the team has earned. Any disciplinary deduction
|
||||||
|
* (see `pointDeduction`) is already subtracted from this value.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
points: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The disciplinary point deduction (deducción de puntos) applied to this
|
||||||
|
* team, when any. Absent when the team has no deduction. The subtraction is
|
||||||
|
* already reflected in `points`; this only carries the amount and reason so
|
||||||
|
* the standings can show a subtle "-N (motivo)" note.
|
||||||
|
*/
|
||||||
|
pointDeduction?: AppliedPointDeduction;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point-deduction summary attached to a standings row when a team carries
|
||||||
|
* one or more disciplinary deductions. Mirrors the backend
|
||||||
|
* `AppliedPointDeductionResponse` DTO.
|
||||||
|
* @type AppliedPointDeduction
|
||||||
|
*/
|
||||||
|
export type AppliedPointDeduction = {
|
||||||
|
/** The total table points subtracted from the team. Always positive. */
|
||||||
|
points: number;
|
||||||
|
|
||||||
|
/** The combined disciplinary reason(s). */
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter criteria for fetching divisions, which extends from PutDivisionRequest and Filtered.
|
||||||
|
* This includes the `isFinished` property to filter divisions by their completion status.
|
||||||
|
* @interface DivisionFiltered
|
||||||
|
* @extends IPutDivisionRequest
|
||||||
|
* @extends Filtered
|
||||||
|
*/
|
||||||
|
export interface DivisionFiltered extends Filtered {
|
||||||
|
/**
|
||||||
|
* Indicates whether to fetch finished divisions only.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isFinished?: boolean;
|
||||||
|
|
||||||
|
tournamentId?: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated name of the division.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body structure for updating an existing division.
|
||||||
|
* @interface PutDivisionRequest
|
||||||
|
*/
|
||||||
|
export interface IPutDivisionRequest {
|
||||||
|
/**
|
||||||
|
* The updated name of the division.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
isFinished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDivisionPropsView {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body to enrol teams in a division's roster.
|
||||||
|
* @interface EnrollTeamsRequest
|
||||||
|
*/
|
||||||
|
export interface EnrollTeamsRequest {
|
||||||
|
teamIds: GUID[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body to remove teams from a division's roster.
|
||||||
|
* @interface UnenrollTeamsRequest
|
||||||
|
*/
|
||||||
|
export interface UnenrollTeamsRequest {
|
||||||
|
teamIds: GUID[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body to change a division's sub-group count (HU-123).
|
||||||
|
* @interface RebuildSubGroupsRequest
|
||||||
|
*/
|
||||||
|
export interface RebuildSubGroupsRequest {
|
||||||
|
subGroupCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request body to manually move one team from one sub-group to another
|
||||||
|
* within the same division (HU-122).
|
||||||
|
* @interface ReassignTeamToSubGroupRequest
|
||||||
|
*/
|
||||||
|
export interface ReassignTeamToSubGroupRequest {
|
||||||
|
teamId: GUID;
|
||||||
|
fromStageId: GUID;
|
||||||
|
toStageId: GUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimal response structure for a division, as embedded within a tournament response.
|
||||||
|
* @interface IMinimalDivisionResponse
|
||||||
|
*/
|
||||||
|
export interface IMinimalDivisionResponse {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the division.
|
||||||
|
* @type {GUID}
|
||||||
|
*/
|
||||||
|
id: GUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the division.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the division has finished.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isFinished: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { StageType } from '@/modules/stage/type/stage';
|
||||||
|
import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory';
|
||||||
|
import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d';
|
||||||
|
import { findDivisionStructure } from './divisionStructureSummary';
|
||||||
|
|
||||||
|
const groupStage = (name: string): ITournamentStructureResponse['divisions'][number]['stages'][number] => ({
|
||||||
|
name,
|
||||||
|
bracketName: null,
|
||||||
|
stageType: StageType.Group,
|
||||||
|
isElimination: false,
|
||||||
|
order: 0,
|
||||||
|
bestOf: 1,
|
||||||
|
roundRobinLegs: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cupStages = (bracketName: string) => [
|
||||||
|
{
|
||||||
|
name: `Semifinal ${bracketName}`,
|
||||||
|
bracketName,
|
||||||
|
stageType: StageType.SemiFinal,
|
||||||
|
isElimination: true,
|
||||||
|
order: 1,
|
||||||
|
bestOf: 3,
|
||||||
|
roundRobinLegs: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Final ${bracketName}`,
|
||||||
|
bracketName,
|
||||||
|
stageType: StageType.Final,
|
||||||
|
isElimination: true,
|
||||||
|
order: 2,
|
||||||
|
bestOf: 5,
|
||||||
|
roundRobinLegs: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tournamentStructure: ITournamentStructureResponse = {
|
||||||
|
name: 'Apertura 2026',
|
||||||
|
category: TournamentCategory.Masculine,
|
||||||
|
divisions: [
|
||||||
|
{
|
||||||
|
name: 'Zona A',
|
||||||
|
isCrossDivisionCup: false,
|
||||||
|
pointsForWin: 3,
|
||||||
|
pointsForLoss: 0,
|
||||||
|
qualifiersPerGroup: 1,
|
||||||
|
playoffMappings: [{ id: 'mapping-1', fromPosition: 1, toPosition: 4, destination: 'Copa Oro' } as never],
|
||||||
|
stages: [groupStage('Fase de Grupos'), ...cupStages('Copa Oro')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Copa Club12',
|
||||||
|
isCrossDivisionCup: true,
|
||||||
|
pointsForWin: 2,
|
||||||
|
pointsForLoss: 1,
|
||||||
|
qualifiersPerGroup: 2,
|
||||||
|
playoffMappings: [],
|
||||||
|
stages: [groupStage('Grupo 1'), groupStage('Grupo 2'), ...cupStages('Copa Club12')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('findDivisionStructure', () => {
|
||||||
|
it('resolves a regular zone by name', () => {
|
||||||
|
const summary = findDivisionStructure(tournamentStructure, 'Zona A');
|
||||||
|
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
expect(summary!.zone).toBeDefined();
|
||||||
|
expect(summary!.crossCup).toBeUndefined();
|
||||||
|
expect(summary!.zone!.name).toBe('Zona A');
|
||||||
|
expect(summary!.zone!.cups).toHaveLength(1);
|
||||||
|
expect(summary!.zone!.cups[0].name).toBe('Copa Oro');
|
||||||
|
expect(summary!.review).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the cross-division cup by name', () => {
|
||||||
|
const summary = findDivisionStructure(tournamentStructure, 'Copa Club12');
|
||||||
|
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
expect(summary!.crossCup).toBeDefined();
|
||||||
|
expect(summary!.zone).toBeUndefined();
|
||||||
|
expect(summary!.crossCup!.groupCount).toBe(2);
|
||||||
|
expect(summary!.crossCup!.cups[0].name).toBe('Copa Club12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no division in the tournament matches the given name', () => {
|
||||||
|
expect(findDivisionStructure(tournamentStructure, 'Zona Inexistente')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d';
|
||||||
|
import { structureToWizardState } from '@/views/tournament/wizard/cloneWizard';
|
||||||
|
import { CrossCupConfig, ZoneConfig } from '@/views/tournament/wizard/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One division's structure, resolved out of its tournament's full structure
|
||||||
|
* tree — a regular zone or the cross-division cup, never both. `review`
|
||||||
|
* carries any derivation mismatches found while reconstructing it (the same
|
||||||
|
* checks the tournament-cloning reverse-mapper runs), surfaced here as
|
||||||
|
* advisory notices rather than silently guessed.
|
||||||
|
*/
|
||||||
|
export interface DivisionStructureSummary {
|
||||||
|
zone?: ZoneConfig;
|
||||||
|
crossCup?: CrossCupConfig;
|
||||||
|
review: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a single division's structure by name out of its tournament's
|
||||||
|
* structure tree, reusing the tournament-cloning reverse-mapper
|
||||||
|
* ({@link structureToWizardState}) instead of a second, parallel parser.
|
||||||
|
* Returns null when no division in the tournament matches the given name.
|
||||||
|
*/
|
||||||
|
export const findDivisionStructure = (
|
||||||
|
tournamentStructure: ITournamentStructureResponse,
|
||||||
|
divisionName: string
|
||||||
|
): DivisionStructureSummary | null => {
|
||||||
|
const { state, review } = structureToWizardState(
|
||||||
|
tournamentStructure,
|
||||||
|
tournamentStructure.category
|
||||||
|
);
|
||||||
|
|
||||||
|
const zone = state.zones.find(candidate => candidate.name === divisionName);
|
||||||
|
if (zone) {
|
||||||
|
return { zone, review };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.crossCup.enabled && state.crossCup.name === divisionName) {
|
||||||
|
return { crossCup: state.crossCup, review };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { QualificationRange } from '@/modules/division/type/division.d';
|
||||||
|
import {
|
||||||
|
buildCrossCupGroupQualificationRange,
|
||||||
|
cupTierColor,
|
||||||
|
cupTierMarker,
|
||||||
|
findQualificationRange,
|
||||||
|
} from './qualificationRange';
|
||||||
|
import { cupTier } from '@/design/tokens';
|
||||||
|
|
||||||
|
const ranges: QualificationRange[] = [
|
||||||
|
{ fromPosition: 1, toPosition: 4, cupName: 'Copa Oro', order: 0 },
|
||||||
|
{ fromPosition: 5, toPosition: 8, cupName: 'Copa Plata', order: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('findQualificationRange', () => {
|
||||||
|
it('matches the first position of a range (inclusive lower bound)', () => {
|
||||||
|
expect(findQualificationRange(ranges, 1)?.cupName).toBe('Copa Oro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the last position of a range (inclusive upper bound)', () => {
|
||||||
|
expect(findQualificationRange(ranges, 8)?.cupName).toBe('Copa Plata');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a position inside a range', () => {
|
||||||
|
expect(findQualificationRange(ranges, 6)?.cupName).toBe('Copa Plata');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a position outside every range', () => {
|
||||||
|
expect(findQualificationRange(ranges, 9)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when there are no ranges', () => {
|
||||||
|
expect(findQualificationRange(undefined, 1)).toBeUndefined();
|
||||||
|
expect(findQualificationRange([], 1)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cupTierColor', () => {
|
||||||
|
it('maps order to gold, silver, bronze then the accent', () => {
|
||||||
|
expect(cupTierColor(0)).toBe(cupTier.gold);
|
||||||
|
expect(cupTierColor(1)).toBe(cupTier.silver);
|
||||||
|
expect(cupTierColor(2)).toBe(cupTier.bronze);
|
||||||
|
expect(cupTierColor(3)).toBe(cupTier.accent);
|
||||||
|
expect(cupTierColor(7)).toBe(cupTier.accent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCrossCupGroupQualificationRange', () => {
|
||||||
|
it('builds a single range covering positions 1..qualifiersPerGroup, named after the cup', () => {
|
||||||
|
const result = buildCrossCupGroupQualificationRange({
|
||||||
|
name: 'Copa Club12',
|
||||||
|
qualifiersPerGroup: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ fromPosition: 1, toPosition: 2, cupName: 'Copa Club12', order: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when qualifiersPerGroup is missing or non-positive', () => {
|
||||||
|
expect(
|
||||||
|
buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: undefined })
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: 0 })
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cupTierMarker', () => {
|
||||||
|
it('returns a distinct marker per tier so the legend does not rely on color alone', () => {
|
||||||
|
expect(cupTierMarker(0)).toBe('🟡');
|
||||||
|
expect(cupTierMarker(1)).toBe('⚪');
|
||||||
|
expect(cupTierMarker(2)).toBe('🟠');
|
||||||
|
expect(cupTierMarker(4)).toBe('🔶');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { IDivisionResponse, QualificationRange } from '@/modules/division/type/division.d';
|
||||||
|
import { cupTier } from '@/design/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the qualification range whose [fromPosition, toPosition] span contains
|
||||||
|
* the given 1-based standings position, or `undefined` when no range covers it
|
||||||
|
* (that row does not qualify to any cup). Ranges never overlap (the backend
|
||||||
|
* enforces it), so at most one can match.
|
||||||
|
*/
|
||||||
|
export const findQualificationRange = (
|
||||||
|
ranges: QualificationRange[] | undefined,
|
||||||
|
position: number
|
||||||
|
): QualificationRange | undefined =>
|
||||||
|
ranges?.find(range => position >= range.fromPosition && position <= range.toPosition);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tier color a cup is painted with, by its top-down order: 0 gold, 1
|
||||||
|
* silver, 2 bronze, and the brand-orange accent for any further cup. Reads the
|
||||||
|
* centralized design tokens so the standings highlight and legend never
|
||||||
|
* hardcode a hex.
|
||||||
|
*/
|
||||||
|
export const cupTierColor = (order: number): string => {
|
||||||
|
switch (order) {
|
||||||
|
case 0:
|
||||||
|
return cupTier.gold;
|
||||||
|
case 1:
|
||||||
|
return cupTier.silver;
|
||||||
|
case 2:
|
||||||
|
return cupTier.bronze;
|
||||||
|
default:
|
||||||
|
return cupTier.accent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HU-110/HU-112: a multi-group cross-division cup pools the top
|
||||||
|
* `qualifiersPerGroup` of EVERY internal group into one bracket — there is no
|
||||||
|
* per-division `PlayoffMappings` breakdown to derive from (the cross cup
|
||||||
|
* carries none, see backend `DivisionProfile.cs`). This is the single range
|
||||||
|
* every group's standings table highlights, named after the cup itself.
|
||||||
|
* Returns `undefined` when the division has no positive qualifiers-per-group
|
||||||
|
* (a regular zone, or a misconfigured cross cup).
|
||||||
|
*/
|
||||||
|
export const buildCrossCupGroupQualificationRange = (
|
||||||
|
division: Pick<IDivisionResponse, 'qualifiersPerGroup' | 'name'>
|
||||||
|
): QualificationRange[] | undefined => {
|
||||||
|
if (!division.qualifiersPerGroup || division.qualifiersPerGroup < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fromPosition: 1,
|
||||||
|
toPosition: division.qualifiersPerGroup,
|
||||||
|
cupName: division.name,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small emoji marker per tier, so the legend conveys the cup rank without
|
||||||
|
* relying on color alone (the cup name text carries the full meaning).
|
||||||
|
*/
|
||||||
|
export const cupTierMarker = (order: number): string => {
|
||||||
|
switch (order) {
|
||||||
|
case 0:
|
||||||
|
return '🟡';
|
||||||
|
case 1:
|
||||||
|
return '⚪';
|
||||||
|
case 2:
|
||||||
|
return '🟠';
|
||||||
|
default:
|
||||||
|
return '🔶';
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user