1---2name: react-ant-design-frontend3description: Complete guide for the SE104_VLEAGUE React frontend — pages, services, components, auth flow, i18n, and patterns Use when this capability is needed.4---56# React + Ant Design Frontend Skill78## Tech Stack910| Category | Package | Version |11| ----------------- | ------------------------------- | --------- |12| UI Framework | React | 19.x |13| Component Library | Ant Design | 6.x |14| Routing | react-router-dom | 7.x |15| HTTP Client | Axios | 1.x |16| i18n | i18next + react-i18next | 25.x/16.x |17| Charts | Recharts | 3.x |18| Date | dayjs | 1.x |19| PDF Export | jsPDF + jspdf-autotable | 4.x/5.x |20| Error Tracking | @sentry/react | 10.x |21| Build | Vite | 7.x |22| Test | Vitest + @testing-library/react | 4.x/16.x |23| TypeScript | | 5.9 |2425## Project Structure2627```28apps/web/src/29├── App.tsx # Root routing (public + protected routes)30├── main.tsx # Entry: Sentry, i18n, BrowserRouter, AuthProvider, ThemeProvider31├── auth/ # AuthContext, RequireAuth, RequireRole, auth.types32├── components/ # Shared: ErrorBoundary, EventModal, ExportButton, ImageUpload, LoadingSkeleton, ScoreEditModal33├── lib/34│ ├── api.ts # Axios instance with token refresh interceptor35│ └── i18n.ts # i18next config (vi/en, localStorage detection)36├── locales/37│ ├── vi.ts # Vietnamese translations (~1,244 lines)38│ └── en.ts # English translations (~1,207 lines)39├── pages/ # All page components (PascalCase)40│ ├── __tests__/ # Page component tests41│ ├── match-detail/ # MatchDetailPage sub-components42│ └── reports/ # ReportsPage tab sub-components43├── services/ # API service modules (camelCase)44│ └── __tests__/ # Service tests45├── shell/46│ ├── AppShell.tsx # Protected layout (Sider + Header + Content)47│ ├── PublicLayout.tsx # Public layout (gradient header + footer)48│ ├── ThemeContext.tsx # Dark/light mode context49│ └── menu.ts # Role-based menu items (12 items)50└── utils/51 └── constants.ts # STATUS_MAP, EVENT_TYPE_MAP, POSITION_MAP, CAN_EDIT_ROLES52```5354---5556## All Pages & Routes5758### Public (No Auth)5960| Route | Component | Load | Description |61| ---------------------- | -------------------- | ----- | -------------------------------------- |62| `/login` | `LoginPage` | Eager | Email/password + Google/Facebook OAuth |63| `/register` | `RegisterPage` | Eager | Registration with password rules |64| `/verify-email` | `VerifyEmailPage` | Lazy | OTP email verification with resend |65| `/forgot-password` | `ForgotPasswordPage` | Lazy | Request password reset OTP |66| `/reset-password` | `ResetPasswordPage` | Lazy | Reset password with OTP |67| `/auth/oauth-callback` | `OAuthCallbackPage` | Eager | OAuth redirect token processing |68| `/403` | `ForbiddenPage` | Eager | Access denied |69| `*` | `NotFoundPage` | Lazy | 404 fallback |7071### Public Layout (`PublicLayout`)7273| Route | Component | Description |74| ------------------- | --------------------- | --------------------------- |75| `/public/standings` | `PublicStandingsPage` | League table (no auth) |76| `/public/schedule` | `PublicSchedulePage` | Schedule by round (no auth) |77| `/public/results` | `PublicResultsPage` | Finished results (no auth) |7879### Protected (`RequireAuth` + `AppShell`)8081| Route | Component | Roles | Description |82| ------------------ | -------------------- | ------------- | ------------------------------------------------------------------------- |83| `/` | `DashboardPage` | All | Stats cards, mini standings, upcoming/recent matches, season progress |84| `/teams` | `TeamsPage` | All | CRUD team list with search, logo, stadium link |85| `/teams/:id` | `TeamDetailPage` | All | Team info, roster, match history, standings |86| `/players` | `PlayersPage` | All | CRUD player list, pagination, filters, CSV import |87| `/players/:id` | `PlayerDetailPage` | All | Bio, stats (goals/cards), event timeline, goals-by-round chart |88| `/stadiums` | `StadiumsPage` | ADMIN (guard) | CRUD stadium list — wrapped in `RequireRole` |89| `/stadiums/:id` | `StadiumDetailPage` | ADMIN (guard) | Stadium info, home teams, match history — wrapped in `RequireRole` |90| `/schedule` | `SchedulePage` | All | Generate/publish schedule, edit match details, grouped by round |91| `/seasons` | `SeasonsPage` | ADMIN (guard) | CRUD seasons, team registration panel — wrapped in `RequireRole` |92| `/matches` | `MatchesPage` | All | Match list by round, detail modal, score edit, events, status transitions |93| `/matches/:id` | `MatchDetailPage` | All | Scoreboard, events timeline, roster tabs, stat cards |94| `/standings` | `StandingsPage` | All | Full standings with AFC CL/relegation highlights, top scorers, CSV export |95| `/head-to-head` | `HeadToHeadPage` | All | Compare two teams — wins/draws/goals + match history |96| `/regulations` | `RegulationsPage` | ADMIN (guard) | CRUD regulations per season, seed defaults — wrapped in `RequireRole` |97| `/reports` | `ReportsPage` | All | Tabs: Top Scorers, Card Stats, Team Stats, Charts — PDF/CSV export |98| `/users` | `UsersPage` | ADMIN (guard) | User management (create/role/delete) — wrapped in `RequireRole` |99| `/profile` | `ProfilePage` | All | View/edit profile, logout all |100| `/change-password` | `ChangePasswordPage` | All | Change password form |101| `/sessions` | `SessionsPage` | All | Active sessions, revoke individual/all |102103---104105## All Services & API Methods106107### authApi.ts (17 functions)108109| Function | Method | Endpoint |110| -------------------- | ------ | ---------------------------- |111| `apiLogin` | POST | `/auth/login` |112| `apiRefresh` | POST | `/auth/refresh` |113| `apiLogout` | POST | `/auth/logout` |114| `apiRegister` | POST | `/auth/register` |115| `apiVerifyEmail` | POST | `/auth/verify-email` |116| `apiResendOtp` | POST | `/auth/resend-otp` |117| `apiForgotPassword` | POST | `/auth/forgot-password` |118| `apiResetPassword` | POST | `/auth/reset-password` |119| `apiGetMe` | GET | `/auth/me` |120| `apiChangePassword` | POST | `/auth/change-password` |121| `apiLogoutAll` | POST | `/auth/logout-all` |122| `apiUpdateProfile` | PATCH | `/auth/profile` |123| `apiGetSessions` | GET | `/auth/sessions` |124| `apiRevokeSession` | DELETE | `/auth/sessions/:id` |125| `apiSetPassword` | POST | `/auth/set-password` |126| `getGoogleAuthUrl` | — | Returns `/auth/google` URL |127| `getFacebookAuthUrl` | — | Returns `/auth/facebook` URL |128129### teamApi.ts130131| Function | Method | Endpoint |132| ---------------- | ------ | --------------------------------- |133| `apiGetTeams` | GET | `/teams?page&limit&search&status` |134| `apiGetTeam` | GET | `/teams/:id` |135| `apiCreateTeam` | POST | `/teams` |136| `apiUpdateTeam` | PATCH | `/teams/:id` |137| `apiDeleteTeam` | DELETE | `/teams/:id` |138| `apiGetStadiums` | GET | `/stadiums` |139140### playerApi.ts141142| Function | Method | Endpoint |143| --------------------- | ------ | -------------------------------------------------------- |144| `apiGetPlayers` | GET | `/players?page&limit&search&position&nationality&teamId` |145| `apiGetPlayer` | GET | `/players/:id` |146| `apiCreatePlayer` | POST | `/players` |147| `apiUpdatePlayer` | PATCH | `/players/:id` |148| `apiDeletePlayer` | DELETE | `/players/:id` |149| `apiImportPlayersCsv` | POST | `/players/import` (multipart FormData) |150151### stadiumApi.ts152153| Function | Method | Endpoint |154| ------------------ | ------ | --------------- |155| `apiGetStadiums` | GET | `/stadiums` |156| `apiGetStadium` | GET | `/stadiums/:id` |157| `apiCreateStadium` | POST | `/stadiums` |158| `apiUpdateStadium` | PATCH | `/stadiums/:id` |159| `apiDeleteStadium` | DELETE | `/stadiums/:id` |160161### seasonApi.ts162163| Function | Method | Endpoint |164| ----------------------- | ------ | --------------------- |165| `apiGetSeasons` | GET | `/seasons` |166| `apiGetSeason` | GET | `/seasons/:id` |167| `apiGetCurrentSeason` | GET | `/seasons/current` |168| `apiCreateSeason` | POST | `/seasons` |169| `apiUpdateSeason` | PATCH | `/seasons/:id` |170| `apiDeleteSeason` | DELETE | `/seasons/:id` |171| `apiUpdateSeasonStatus` | PATCH | `/seasons/:id/status` |172173### seasonTeamApi.ts174175| Function | Method | Endpoint |176| --------------------------- | ------ | ----------------------------------------- |177| `apiGetSeasonTeams` | GET | `/seasons/:seasonId/teams` |178| `apiRegisterTeam` | POST | `/seasons/:seasonId/teams` |179| `apiUpdateSeasonTeamStatus` | PATCH | `/seasons/:seasonId/teams/:teamId/status` |180| `apiRemoveSeasonTeam` | DELETE | `/seasons/:seasonId/teams/:teamId` |181182### matchApi.ts183184| Function | Method | Endpoint |185| ---------------------- | ------ | ------------------------------ |186| `apiGetMatches` | GET | `/matches?seasonId&page&limit` |187| `apiGetMatch` | GET | `/matches/:id` |188| `apiAddMatchEvent` | POST | `/matches/:id/events` |189| `apiRemoveMatchEvent` | DELETE | `/matches/:id/events/:eventId` |190| `apiGetTeamRoster` | GET | `/teams/:id/roster` |191| `apiUpdateMatch` | PATCH | `/matches/:id` |192| `apiUpdateMatchStatus` | PATCH | `/matches/:id/status` |193194### scheduleApi.ts195196| Function | Method | Endpoint |197| --------------------- | ------ | ----------------------------- |198| `apiGetSchedule` | GET | `/schedule?seasonId` |199| `apiGenerateSchedule` | POST | `/schedule/generate?seasonId` |200| `apiPublishSchedule` | POST | `/schedule/publish?seasonId` |201202### standingsApi.ts203204| Function | Method | Endpoint |205| ------------------ | ------ | --------------------------------------- |206| `apiGetStandings` | GET | `/standings?seasonId` |207| `apiGetTopScorers` | GET | `/standings/top-scorers?seasonId&limit` |208| `apiGetCardStats` | GET | `/standings/card-stats?seasonId&limit` |209| `apiGetTeamStats` | GET | `/standings/team-stats?seasonId` |210211### searchApi.ts212213| Function | Method | Endpoint |214| ------------------- | ------ | ---------------------------------------------- |215| `apiGlobalSearch` | GET | `/search?q&limit` |216| `apiGetHeadToHead` | GET | `/standings/head-to-head?team1&team2&seasonId` |217| `apiGetPlayerStats` | GET | `/standings/player-stats/:playerId?seasonId` |218219### regulationApi.ts220221| Function | Method | Endpoint |222| --------------------------- | ------ | ---------------------------------------- |223| `apiGetRegulations` | GET | `/seasons/:id/regulations` |224| `apiGetRegulation` | GET | `/seasons/:id/regulations/:key` |225| `apiUpsertRegulation` | PUT | `/seasons/:id/regulations` |226| `apiDeleteRegulation` | DELETE | `/seasons/:id/regulations/:key` |227| `apiSeedDefaultRegulations` | POST | `/seasons/:id/regulations/seed-defaults` |228229### userApi.ts230231| Function | Method | Endpoint |232| ------------------- | ------ | ----------------- |233| `apiGetUsers` | GET | `/users` |234| `apiCreateUser` | POST | `/users` |235| `apiUpdateUserRole` | PATCH | `/users/:id/role` |236| `apiDeleteUser` | DELETE | `/users/:id` |237238### uploadApi.ts239240| Function | Method | Endpoint |241| ---------------- | ------ | ------------------------------------ |242| `apiUploadImage` | POST | `/upload/image` (multipart FormData) |243244### http.ts (LEGACY — not used)245246`fetch`-based HTTP client. All active services import from `lib/api.ts` (Axios) instead.247248---249250## Shared Components (`src/components/`)251252| Component | Purpose |253| ----------------- | ---------------------------------------------------------------------------------------------------- |254| `ErrorBoundary` | Class component catching runtime errors → Ant Design `Result`, reports to Sentry, shows stack in dev |255| `EventModal` | Dynamic form for adding multiple match events at once (goal, card, sub) with team/player selectors |256| `ExportButton` | CSV export button with UTF-8 BOM for Excel Vietnamese compatibility |257| `ImageUpload` | Ant Design `Upload` wrapper for `/upload/image`, form-compatible (`value`/`onChange`) |258| `LoadingSkeleton` | Variants: `CardSkeleton`, `TableSkeleton`, `FormSkeleton`, `ProfileSkeleton`, `ListSkeleton` |259| `ScoreEditModal` | Modal for editing home/away score with `InputNumber` |260261### Page Sub-Components262263| Location | Components |264| --------------------- | -------------------------------------------------------------------------------- |265| `pages/reports/` | `TopScorersTab`, `CardStatsTab`, `TeamStatsTab`, `ChartsTab` |266| `pages/match-detail/` | `EventFormModal`, `ScoreModal`, `constants.ts` (match-detail specific constants) |267268---269270## Auth Flow271272### Types (`auth/auth.types.ts`)273274```typescript275User { id, email, role, name? }276AuthState { user, accessToken, isAuthed }277AuthContextValue = AuthState & { login, logout, applyOAuthTokens }278```279280### AuthContext (`auth/AuthContext.tsx`)281282- **Access token**: in-memory only (NEVER in localStorage)283- **Refresh token**: in `localStorage` for persistence284- **Bootstrap**: On mount → silent refresh via `/auth/refresh` → decode JWT for `User`285- **Login**: POST `/auth/login` → stores both tokens286- **OAuth**: `applyOAuthTokens(at, rt)` → decode JWT → set user287- **Logout**: POST `/auth/logout` → clears both tokens288- **Session expiry**: Listens for `auth:expired` custom event289290### Token Refresh (`lib/api.ts`)291292- Axios response interceptor on 401 → attempts refresh using stored RT293- Queues concurrent failing requests during refresh (only one refresh runs at a time)294- Dispatches `auth:expired` custom event when refresh fails295- Also handles 429 → dispatches `api:rate-limited` custom event296297### Route Guards298299- **`RequireAuth`**: Redirects to `/login` if `!isAuthed`, preserves intended URL in `location.state`300- **`RequireRole`**: Checks `user.role` against `allow[]` array, redirects to `/403`301302### Roles303304`ADMIN` | `TEAM_MANAGER` | `REFEREE` | `SUPERVISOR` | `PUBLIC`305306---307308## Shell / Layout309310### AppShell (`shell/AppShell.tsx`) — Protected311312- Ant Design `Layout` with collapsible `Sider` + `Header` + `Content`313- **Sidebar**: Dynamic menu filtered by user role (from `menu.ts`)314- **Header**: Global search (debounced 300ms autocomplete via `/search`), dark mode toggle, language toggle (VI/EN), user dropdown (profile, change password, logout)315- `<Outlet />` renders child routes316317### PublicLayout (`shell/PublicLayout.tsx`)318319- Gradient header with V-League branding320- Horizontal menu: Standings, Schedule, Results321- Login button322- Footer with copyright323324### ThemeContext (`shell/ThemeContext.tsx`)325326- Light/dark mode toggle via React Context327- Persisted to `localStorage` key `vleague-theme`328- Returns `theme.darkAlgorithm` or `theme.defaultAlgorithm` for Ant Design329330### Menu (`shell/menu.ts`) — 12 items331332| Menu Item | Roles |333| ------------------------------------------- | ------------------------------------------------ |334| Dashboard, Standings, Head-to-Head, Reports | All |335| Seasons, Stadiums, Regulations, Users | ADMIN only |336| Teams, Players | ADMIN, TEAM_MANAGER, SUPERVISOR, PUBLIC |337| Schedule, Matches | ADMIN, TEAM_MANAGER, REFEREE, SUPERVISOR, PUBLIC |338339---340341## i18n Setup342343### Configuration (`lib/i18n.ts`)344345- **Languages**: Vietnamese (`vi`, fallback) and English (`en`)346- **Detection**: `localStorage` key `vleague-lang` → browser navigator347- **Plugin**: `i18next-browser-languagedetector`348349### Translation Files350351- `locales/vi.ts` — ~1,244 lines, comprehensive Vietnamese catalog352- `locales/en.ts` — ~1,207 lines, English mirror353354### Namespace Convention355356`dashboard.*`, `matches.*`, `teams.*`, `players.*`, `seasons.*`, `schedule.*`, `standings.*`, `stadiums.*`, `users.*`, `profile.*`, `sessions.*`, `regulations.*`, `reports.*`, `headToHead.*`, `login.*`, `register.*`, `forgotPassword.*`, `resetPassword.*`, `changePassword.*`, `verifyEmail.*`, `teamDetail.*`, `playerDetail.*`, `stadiumDetail.*`, `forbidden.*`, `oauth.*`357358---359360## TypeScript Types361362### Domain Types (co-located in service files)363364| Type | Key Fields |365| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |366| `Team` | id, name, shortName?, logoUrl?, city?, status, stadiumId?, stadium? |367| `TeamDetail` | extends Team + teamPlayers[], homeMatches[], awayMatches[], standings[] |368| `Player` | id, fullName, dob, nationality, position, playerType, birthPlace?, heightCm?, weightKg?, teamPlayers? |369| `Stadium` | id, name, address?, city, capacity? |370| `StadiumDetail` | extends Stadium + teams[], matches[] |371| `Season` | id, name, year, status, startDate?, endDate? |372| `SeasonTeam` | id, seasonId, teamId, status, registeredAt, approvedAt, team |373| `Match` | id, roundNo, leg, seasonId?, homeTeamId, awayTeamId, homeTeam?, awayTeam?, homeScore?, awayScore?, stadiumId?, stadium?, kickoffAt?, status, events? |374| `MatchEvent` | id, minute, type, goalType?, playerId?, player?, teamId?, team?, relatedPlayerId?, relatedPlayer?, note? |375| `TeamStanding` | position, teamId, teamName, played, won, drawn, lost, goalsFor, goalsAgainst, goalDifference, points |376| `TopScorer` | position, playerId, playerName, teamId, teamName, goals |377| `CardStat` | position, playerId, playerName, teamId, teamName, yellowCards, redCards, totalCards |378| `TeamStat` | extends standings + cleanSheets, yellowCards, redCards |379| `HeadToHeadResult` | team comparison data |380| `PlayerStats` | goals, assists, cards, goals-by-round |381| `Regulation` | id, seasonId, key, value, valueType |382| `User` | id, email, name?, role, emailVerified, avatarUrl?, googleId?, facebookId? |383| `Session` | id, deviceName, userAgent, ipAddress, lastUsedAt, createdAt, expiresAt |384| `PaginatedResponse<T>` | data[], total, page, limit, totalPages |385386### Enums (string unions)387388- **Position**: `'GK' | 'DF' | 'MF' | 'FW'`389- **PlayerType**: `'DOMESTIC' | 'FOREIGN'`390- **MatchStatus**: `'DRAFT' | 'PUBLISHED' | 'LOCKED' | 'FINISHED' | 'POSTPONED'`391- **SeasonStatus**: `'UPCOMING' | 'IN_PROGRESS' | 'COMPLETED'`392- **SeasonTeamStatus**: `'REGISTERED' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'`393- **EventType**: `'GOAL' | 'OWN_GOAL' | 'PENALTY' | 'PENALTY_MISS' | 'YELLOW_CARD' | 'RED_CARD' | 'SUBSTITUTION'`394- **UserRole**: `'ADMIN' | 'TEAM_MANAGER' | 'REFEREE' | 'SUPERVISOR' | 'PUBLIC'`395- **ThemeMode**: `'light' | 'dark'`396397---398399## State Management Patterns400401**No Redux/Zustand** — pure React patterns:402403- **`AuthContext`**: Global auth state (React Context + `useState`)404- **`ThemeContext`**: Dark/light mode (React Context + `useState`)405- **Per-page local state**: `useState` + `useEffect` for data fetching406- **`useCallback`**: Memoized fetch functions407- **`useMemo`**: Derived data (filtered lists, role checks, menu items)408- **`Promise.allSettled`**: Parallel API calls on Dashboard and Reports409- **`useRef`**: Debounce timers (global search), prevent duplicate processing (OAuth)410- **Custom events**: `auth:expired`, `api:rate-limited` for cross-cutting concerns411412---413414## Environment Variables415416| Variable | Usage |417| ------------------- | ------------------------------------------------------ |418| `VITE_API_BASE_URL` | Backend API URL (default: `http://localhost:8080/api`) |419| `VITE_SENTRY_DSN` | Sentry DSN (optional, no-op when unset) |420| `VITE_APP_VERSION` | Sentry release tag |421422---423424## Utility Constants (`utils/constants.ts`)425426| Constant | Content |427| ------------------- | -------------------------------------------------- |428| `STATUS_MAP` | Match statuses → `{ label, color }` (admin labels) |429| `PUBLIC_STATUS_MAP` | Match statuses → user-friendly labels |430| `EVENT_TYPE_MAP` | Event types → `{ label, color, icon }` |431| `POSITION_MAP` | `GK/DF/MF/FW` → `{ label, color }` |432| `CAN_EDIT_ROLES` | `['ADMIN', 'REFEREE']` |433434---435436## Notable Patterns4374381. **Lazy loading**: All protected pages + layouts are `React.lazy()`. Auth pages eager for fast first paint4392. **Dual HTTP clients**: `lib/api.ts` (Axios, primary) + `services/http.ts` (fetch, legacy/unused)4403. **Token refresh queue**: Concurrent 401s queued; only one refresh runs at a time4414. **PDF export**: Dynamic `import('jspdf')` to avoid bundling unless needed4425. **CSV export**: `ExportButton` component + `csvExport.ts` utility (UTF-8 BOM for Excel)4436. **Player CSV import**: `apiImportPlayersCsv` sends FormData; `downloadPlayerCsvTemplate()` for template4447. **Match status FSM**: `DRAFT → PUBLISHED → LOCKED → FINISHED` (with POSTPONED side-paths)4458. **Standings highlighting**: AFC Champions League zone (green) + relegation zone (red) via CSS classes4469. **Session management**: View active sessions, revoke individual/all44710. **OAuth flow**: Server redirect → `/auth/oauth-callback?accessToken=&refreshToken=`44811. **Global search**: Debounced 300ms autocomplete in AppShell header44912. **Recharts**: Bar charts (goals by round, team points), Pie chart (goal distribution)45013. **Sentry**: Browser tracing + session replay (100% dev / 20% prod)451452---453454## Testing Conventions455456- **15 page test suites, 13 service test suites, 2 auth test suites**457- Page tests: `src/pages/__tests__/*.test.tsx`458- Service tests: `src/services/__tests__/*.test.ts`459- Auth tests: `src/auth/AuthContext.test.tsx`, `RequireAuth.test.tsx`460- **Always use `vi.hoisted()`** for mock variables inside `vi.mock()`461- Mock `../../lib/api` for service tests462- Mock individual service files + `react-router-dom` for page tests463- Use `getAllByText()` instead of `getByText()` for Ant Design duplicate text464- Import from `__tests__/` goes up one level: `../ComponentName`465466### Polyfills (`vitest.setup.ts`)467468- `@testing-library/jest-dom/vitest` for custom matchers469- i18n initialized with Vietnamese, no language detector470- `ResizeObserver` polyfill (Ant Design Tabs/Collapse)471- `window.matchMedia` polyfill (Ant Design responsive)472473---474475## Common Commands476477```bash478cd apps/web479pnpm dev # Start dev server (port 5173)480pnpm build # Production build481pnpm preview # Preview production build482pnpm test # Vitest (30 suites)483pnpm exec vitest # Watch mode484pnpm lint # ESLint485```486487---488> Converted and distributed by [TomeVault](https://tomevault.io/claim/daithang-organization) — claim your Tome and manage your conversions.489<!-- tomevault:4.0:skill_md:2026-04-13 -->