React + Ant Design Frontend Skill
Tech Stack
| Category |
Package |
Version |
| UI Framework |
React |
19.x |
| Component Library |
Ant Design |
6.x |
| Routing |
react-router-dom |
7.x |
| HTTP Client |
Axios |
1.x |
| i18n |
i18next + react-i18next |
25.x/16.x |
| Charts |
Recharts |
3.x |
| Date |
dayjs |
1.x |
| PDF Export |
jsPDF + jspdf-autotable |
4.x/5.x |
| Error Tracking |
@sentry/react |
10.x |
| Build |
Vite |
7.x |
| Test |
Vitest + @testing-library/react |
4.x/16.x |
| TypeScript |
|
5.9 |
Project Structure
apps/web/src/
āāā App.tsx # Root routing (public + protected routes)
āāā main.tsx # Entry: Sentry, i18n, BrowserRouter, AuthProvider, ThemeProvider
āāā auth/ # AuthContext, RequireAuth, RequireRole, auth.types
āāā components/ # Shared: ErrorBoundary, EventModal, ExportButton, ImageUpload, LoadingSkeleton, ScoreEditModal
āāā lib/
ā āāā api.ts # Axios instance with token refresh interceptor
ā āāā i18n.ts # i18next config (vi/en, localStorage detection)
āāā locales/
ā āāā vi.ts # Vietnamese translations (~1,244 lines)
ā āāā en.ts # English translations (~1,207 lines)
āāā pages/ # All page components (PascalCase)
ā āāā __tests__/ # Page component tests
ā āāā match-detail/ # MatchDetailPage sub-components
ā āāā reports/ # ReportsPage tab sub-components
āāā services/ # API service modules (camelCase)
ā āāā __tests__/ # Service tests
āāā shell/
ā āāā AppShell.tsx # Protected layout (Sider + Header + Content)
ā āāā PublicLayout.tsx # Public layout (gradient header + footer)
ā āāā ThemeContext.tsx # Dark/light mode context
ā āāā menu.ts # Role-based menu items (12 items)
āāā utils/
āāā constants.ts # STATUS_MAP, EVENT_TYPE_MAP, POSITION_MAP, CAN_EDIT_ROLES
All Pages & Routes
Public (No Auth)
| Route |
Component |
Load |
Description |
/login |
LoginPage |
Eager |
Email/password + Google/Facebook OAuth |
/register |
RegisterPage |
Eager |
Registration with password rules |
/verify-email |
VerifyEmailPage |
Lazy |
OTP email verification with resend |
/forgot-password |
ForgotPasswordPage |
Lazy |
Request password reset OTP |
/reset-password |
ResetPasswordPage |
Lazy |
Reset password with OTP |
/auth/oauth-callback |
OAuthCallbackPage |
Eager |
OAuth redirect token processing |
/403 |
ForbiddenPage |
Eager |
Access denied |
* |
NotFoundPage |
Lazy |
404 fallback |
Public Layout (PublicLayout)
| Route |
Component |
Description |
/public/standings |
PublicStandingsPage |
League table (no auth) |
/public/schedule |
PublicSchedulePage |
Schedule by round (no auth) |
/public/results |
PublicResultsPage |
Finished results (no auth) |
Protected (RequireAuth + AppShell)
| Route |
Component |
Roles |
Description |
/ |
DashboardPage |
All |
Stats cards, mini standings, upcoming/recent matches, season progress |
/teams |
TeamsPage |
All |
CRUD team list with search, logo, stadium link |
/teams/:id |
TeamDetailPage |
All |
Team info, roster, match history, standings |
/players |
PlayersPage |
All |
CRUD player list, pagination, filters, CSV import |
/players/:id |
PlayerDetailPage |
All |
Bio, stats (goals/cards), event timeline, goals-by-round chart |
/stadiums |
StadiumsPage |
ADMIN (guard) |
CRUD stadium list ā wrapped in RequireRole |
/stadiums/:id |
StadiumDetailPage |
ADMIN (guard) |
Stadium info, home teams, match history ā wrapped in RequireRole |
/schedule |
SchedulePage |
All |
Generate/publish schedule, edit match details, grouped by round |
/seasons |
SeasonsPage |
ADMIN (guard) |
CRUD seasons, team registration panel ā wrapped in RequireRole |
/matches |
MatchesPage |
All |
Match list by round, detail modal, score edit, events, status transitions |
/matches/:id |
MatchDetailPage |
All |
Scoreboard, events timeline, roster tabs, stat cards |
/standings |
StandingsPage |
All |
Full standings with AFC CL/relegation highlights, top scorers, CSV export |
/head-to-head |
HeadToHeadPage |
All |
Compare two teams ā wins/draws/goals + match history |
/regulations |
RegulationsPage |
ADMIN (guard) |
CRUD regulations per season, seed defaults ā wrapped in RequireRole |
/reports |
ReportsPage |
All |
Tabs: Top Scorers, Card Stats, Team Stats, Charts ā PDF/CSV export |
/users |
UsersPage |
ADMIN (guard) |
User management (create/role/delete) ā wrapped in RequireRole |
/profile |
ProfilePage |
All |
View/edit profile, logout all |
/change-password |
ChangePasswordPage |
All |
Change password form |
/sessions |
SessionsPage |
All |
Active sessions, revoke individual/all |
All Services & API Methods
authApi.ts (17 functions)
| Function |
Method |
Endpoint |
apiLogin |
POST |
/auth/login |
apiRefresh |
POST |
/auth/refresh |
apiLogout |
POST |
/auth/logout |
apiRegister |
POST |
/auth/register |
apiVerifyEmail |
POST |
/auth/verify-email |
apiResendOtp |
POST |
/auth/resend-otp |
apiForgotPassword |
POST |
/auth/forgot-password |
apiResetPassword |
POST |
/auth/reset-password |
apiGetMe |
GET |
/auth/me |
apiChangePassword |
POST |
/auth/change-password |
apiLogoutAll |
POST |
/auth/logout-all |
apiUpdateProfile |
PATCH |
/auth/profile |
apiGetSessions |
GET |
/auth/sessions |
apiRevokeSession |
DELETE |
/auth/sessions/:id |
apiSetPassword |
POST |
/auth/set-password |
getGoogleAuthUrl |
ā |
Returns /auth/google URL |
getFacebookAuthUrl |
ā |
Returns /auth/facebook URL |
teamApi.ts
| Function |
Method |
Endpoint |
apiGetTeams |
GET |
/teams?page&limit&search&status |
apiGetTeam |
GET |
/teams/:id |
apiCreateTeam |
POST |
/teams |
apiUpdateTeam |
PATCH |
/teams/:id |
apiDeleteTeam |
DELETE |
/teams/:id |
apiGetStadiums |
GET |
/stadiums |
playerApi.ts
| Function |
Method |
Endpoint |
apiGetPlayers |
GET |
/players?page&limit&search&position&nationality&teamId |
apiGetPlayer |
GET |
/players/:id |
apiCreatePlayer |
POST |
/players |
apiUpdatePlayer |
PATCH |
/players/:id |
apiDeletePlayer |
DELETE |
/players/:id |
apiImportPlayersCsv |
POST |
/players/import (multipart FormData) |
stadiumApi.ts
| Function |
Method |
Endpoint |
apiGetStadiums |
GET |
/stadiums |
apiGetStadium |
GET |
/stadiums/:id |
apiCreateStadium |
POST |
/stadiums |
apiUpdateStadium |
PATCH |
/stadiums/:id |
apiDeleteStadium |
DELETE |
/stadiums/:id |
seasonApi.ts
| Function |
Method |
Endpoint |
apiGetSeasons |
GET |
/seasons |
apiGetSeason |
GET |
/seasons/:id |
apiGetCurrentSeason |
GET |
/seasons/current |
apiCreateSeason |
POST |
/seasons |
apiUpdateSeason |
PATCH |
/seasons/:id |
apiDeleteSeason |
DELETE |
/seasons/:id |
apiUpdateSeasonStatus |
PATCH |
/seasons/:id/status |
seasonTeamApi.ts
| Function |
Method |
Endpoint |
apiGetSeasonTeams |
GET |
/seasons/:seasonId/teams |
apiRegisterTeam |
POST |
/seasons/:seasonId/teams |
apiUpdateSeasonTeamStatus |
PATCH |
/seasons/:seasonId/teams/:teamId/status |
apiRemoveSeasonTeam |
DELETE |
/seasons/:seasonId/teams/:teamId |
matchApi.ts
| Function |
Method |
Endpoint |
apiGetMatches |
GET |
/matches?seasonId&page&limit |
apiGetMatch |
GET |
/matches/:id |
apiAddMatchEvent |
POST |
/matches/:id/events |
apiRemoveMatchEvent |
DELETE |
/matches/:id/events/:eventId |
apiGetTeamRoster |
GET |
/teams/:id/roster |
apiUpdateMatch |
PATCH |
/matches/:id |
apiUpdateMatchStatus |
PATCH |
/matches/:id/status |
scheduleApi.ts
| Function |
Method |
Endpoint |
apiGetSchedule |
GET |
/schedule?seasonId |
apiGenerateSchedule |
POST |
/schedule/generate?seasonId |
apiPublishSchedule |
POST |
/schedule/publish?seasonId |
standingsApi.ts
| Function |
Method |
Endpoint |
apiGetStandings |
GET |
/standings?seasonId |
apiGetTopScorers |
GET |
/standings/top-scorers?seasonId&limit |
apiGetCardStats |
GET |
/standings/card-stats?seasonId&limit |
apiGetTeamStats |
GET |
/standings/team-stats?seasonId |
searchApi.ts
| Function |
Method |
Endpoint |
apiGlobalSearch |
GET |
/search?q&limit |
apiGetHeadToHead |
GET |
/standings/head-to-head?team1&team2&seasonId |
apiGetPlayerStats |
GET |
/standings/player-stats/:playerId?seasonId |
regulationApi.ts
| Function |
Method |
Endpoint |
apiGetRegulations |
GET |
/seasons/:id/regulations |
apiGetRegulation |
GET |
/seasons/:id/regulations/:key |
apiUpsertRegulation |
PUT |
/seasons/:id/regulations |
apiDeleteRegulation |
DELETE |
/seasons/:id/regulations/:key |
apiSeedDefaultRegulations |
POST |
/seasons/:id/regulations/seed-defaults |
userApi.ts
| Function |
Method |
Endpoint |
apiGetUsers |
GET |
/users |
apiCreateUser |
POST |
/users |
apiUpdateUserRole |
PATCH |
/users/:id/role |
apiDeleteUser |
DELETE |
/users/:id |
uploadApi.ts
| Function |
Method |
Endpoint |
apiUploadImage |
POST |
/upload/image (multipart FormData) |
http.ts (LEGACY ā not used)
fetch-based HTTP client. All active services import from lib/api.ts (Axios) instead.
Shared Components (src/components/)
| Component |
Purpose |
ErrorBoundary |
Class component catching runtime errors ā Ant Design Result, reports to Sentry, shows stack in dev |
EventModal |
Dynamic form for adding multiple match events at once (goal, card, sub) with team/player selectors |
ExportButton |
CSV export button with UTF-8 BOM for Excel Vietnamese compatibility |
ImageUpload |
Ant Design Upload wrapper for /upload/image, form-compatible (value/onChange) |
LoadingSkeleton |
Variants: CardSkeleton, TableSkeleton, FormSkeleton, ProfileSkeleton, ListSkeleton |
ScoreEditModal |
Modal for editing home/away score with InputNumber |
Page Sub-Components
| Location |
Components |
pages/reports/ |
TopScorersTab, CardStatsTab, TeamStatsTab, ChartsTab |
pages/match-detail/ |
EventFormModal, ScoreModal, constants.ts (match-detail specific constants) |
Auth Flow
Types (auth/auth.types.ts)
User { id, email, role, name? }
AuthState { user, accessToken, isAuthed }
AuthContextValue = AuthState & { login, logout, applyOAuthTokens }
AuthContext (auth/AuthContext.tsx)
- Access token: in-memory only (NEVER in localStorage)
- Refresh token: in
localStorage for persistence
- Bootstrap: On mount ā silent refresh via
/auth/refresh ā decode JWT for User
- Login: POST
/auth/login ā stores both tokens
- OAuth:
applyOAuthTokens(at, rt) ā decode JWT ā set user
- Logout: POST
/auth/logout ā clears both tokens
- Session expiry: Listens for
auth:expired custom event
Token Refresh (lib/api.ts)
- Axios response interceptor on 401 ā attempts refresh using stored RT
- Queues concurrent failing requests during refresh (only one refresh runs at a time)
- Dispatches
auth:expired custom event when refresh fails
- Also handles 429 ā dispatches
api:rate-limited custom event
Route Guards
RequireAuth: Redirects to /login if !isAuthed, preserves intended URL in location.state
RequireRole: Checks user.role against allow[] array, redirects to /403
Roles
ADMIN | TEAM_MANAGER | REFEREE | SUPERVISOR | PUBLIC
Shell / Layout
AppShell (shell/AppShell.tsx) ā Protected
- Ant Design
Layout with collapsible Sider + Header + Content
- Sidebar: Dynamic menu filtered by user role (from
menu.ts)
- Header: Global search (debounced 300ms autocomplete via
/search), dark mode toggle, language toggle (VI/EN), user dropdown (profile, change password, logout)
<Outlet /> renders child routes
PublicLayout (shell/PublicLayout.tsx)
- Gradient header with V-League branding
- Horizontal menu: Standings, Schedule, Results
- Login button
- Footer with copyright
ThemeContext (shell/ThemeContext.tsx)
- Light/dark mode toggle via React Context
- Persisted to
localStorage key vleague-theme
- Returns
theme.darkAlgorithm or theme.defaultAlgorithm for Ant Design
Menu (shell/menu.ts) ā 12 items
| Menu Item |
Roles |
| Dashboard, Standings, Head-to-Head, Reports |
All |
| Seasons, Stadiums, Regulations, Users |
ADMIN only |
| Teams, Players |
ADMIN, TEAM_MANAGER, SUPERVISOR, PUBLIC |
| Schedule, Matches |
ADMIN, TEAM_MANAGER, REFEREE, SUPERVISOR, PUBLIC |
i18n Setup
Configuration (lib/i18n.ts)
- Languages: Vietnamese (
vi, fallback) and English (en)
- Detection:
localStorage key vleague-lang ā browser navigator
- Plugin:
i18next-browser-languagedetector
Translation Files
locales/vi.ts ā ~1,244 lines, comprehensive Vietnamese catalog
locales/en.ts ā ~1,207 lines, English mirror
Namespace Convention
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.*
TypeScript Types
Domain Types (co-located in service files)
| Type |
Key Fields |
Team |
id, name, shortName?, logoUrl?, city?, status, stadiumId?, stadium? |
TeamDetail |
extends Team + teamPlayers[], homeMatches[], awayMatches[], standings[] |
Player |
id, fullName, dob, nationality, position, playerType, birthPlace?, heightCm?, weightKg?, teamPlayers? |
Stadium |
id, name, address?, city, capacity? |
StadiumDetail |
extends Stadium + teams[], matches[] |
Season |
id, name, year, status, startDate?, endDate? |
SeasonTeam |
id, seasonId, teamId, status, registeredAt, approvedAt, team |
Match |
id, roundNo, leg, seasonId?, homeTeamId, awayTeamId, homeTeam?, awayTeam?, homeScore?, awayScore?, stadiumId?, stadium?, kickoffAt?, status, events? |
MatchEvent |
id, minute, type, goalType?, playerId?, player?, teamId?, team?, relatedPlayerId?, relatedPlayer?, note? |
TeamStanding |
position, teamId, teamName, played, won, drawn, lost, goalsFor, goalsAgainst, goalDifference, points |
TopScorer |
position, playerId, playerName, teamId, teamName, goals |
CardStat |
position, playerId, playerName, teamId, teamName, yellowCards, redCards, totalCards |
TeamStat |
extends standings + cleanSheets, yellowCards, redCards |
HeadToHeadResult |
team comparison data |
PlayerStats |
goals, assists, cards, goals-by-round |
Regulation |
id, seasonId, key, value, valueType |
User |
id, email, name?, role, emailVerified, avatarUrl?, googleId?, facebookId? |
Session |
id, deviceName, userAgent, ipAddress, lastUsedAt, createdAt, expiresAt |
PaginatedResponse<T> |
data[], total, page, limit, totalPages |
Enums (string unions)
- Position:
'GK' | 'DF' | 'MF' | 'FW'
- PlayerType:
'DOMESTIC' | 'FOREIGN'
- MatchStatus:
'DRAFT' | 'PUBLISHED' | 'LOCKED' | 'FINISHED' | 'POSTPONED'
- SeasonStatus:
'UPCOMING' | 'IN_PROGRESS' | 'COMPLETED'
- SeasonTeamStatus:
'REGISTERED' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
- EventType:
'GOAL' | 'OWN_GOAL' | 'PENALTY' | 'PENALTY_MISS' | 'YELLOW_CARD' | 'RED_CARD' | 'SUBSTITUTION'
- UserRole:
'ADMIN' | 'TEAM_MANAGER' | 'REFEREE' | 'SUPERVISOR' | 'PUBLIC'
- ThemeMode:
'light' | 'dark'
State Management Patterns
No Redux/Zustand ā pure React patterns:
AuthContext: Global auth state (React Context + useState)
ThemeContext: Dark/light mode (React Context + useState)
- Per-page local state:
useState + useEffect for data fetching
useCallback: Memoized fetch functions
useMemo: Derived data (filtered lists, role checks, menu items)
Promise.allSettled: Parallel API calls on Dashboard and Reports
useRef: Debounce timers (global search), prevent duplicate processing (OAuth)
- Custom events:
auth:expired, api:rate-limited for cross-cutting concerns
Environment Variables
| Variable |
Usage |
VITE_API_BASE_URL |
Backend API URL (default: http://localhost:8080/api) |
VITE_SENTRY_DSN |
Sentry DSN (optional, no-op when unset) |
VITE_APP_VERSION |
Sentry release tag |
Utility Constants (utils/constants.ts)
| Constant |
Content |
STATUS_MAP |
Match statuses ā { label, color } (admin labels) |
PUBLIC_STATUS_MAP |
Match statuses ā user-friendly labels |
EVENT_TYPE_MAP |
Event types ā { label, color, icon } |
POSITION_MAP |
GK/DF/MF/FW ā { label, color } |
CAN_EDIT_ROLES |
['ADMIN', 'REFEREE'] |
Notable Patterns
- Lazy loading: All protected pages + layouts are
React.lazy(). Auth pages eager for fast first paint
- Dual HTTP clients:
lib/api.ts (Axios, primary) + services/http.ts (fetch, legacy/unused)
- Token refresh queue: Concurrent 401s queued; only one refresh runs at a time
- PDF export: Dynamic
import('jspdf') to avoid bundling unless needed
- CSV export:
ExportButton component + csvExport.ts utility (UTF-8 BOM for Excel)
- Player CSV import:
apiImportPlayersCsv sends FormData; downloadPlayerCsvTemplate() for template
- Match status FSM:
DRAFT ā PUBLISHED ā LOCKED ā FINISHED (with POSTPONED side-paths)
- Standings highlighting: AFC Champions League zone (green) + relegation zone (red) via CSS classes
- Session management: View active sessions, revoke individual/all
- OAuth flow: Server redirect ā
/auth/oauth-callback?accessToken=&refreshToken=
- Global search: Debounced 300ms autocomplete in AppShell header
- Recharts: Bar charts (goals by round, team points), Pie chart (goal distribution)
- Sentry: Browser tracing + session replay (100% dev / 20% prod)
Testing Conventions
- 15 page test suites, 13 service test suites, 2 auth test suites
- Page tests:
src/pages/__tests__/*.test.tsx
- Service tests:
src/services/__tests__/*.test.ts
- Auth tests:
src/auth/AuthContext.test.tsx, RequireAuth.test.tsx
- Always use
vi.hoisted() for mock variables inside vi.mock()
- Mock
../../lib/api for service tests
- Mock individual service files +
react-router-dom for page tests
- Use
getAllByText() instead of getByText() for Ant Design duplicate text
- Import from
__tests__/ goes up one level: ../ComponentName
Polyfills (vitest.setup.ts)
@testing-library/jest-dom/vitest for custom matchers
- i18n initialized with Vietnamese, no language detector
ResizeObserver polyfill (Ant Design Tabs/Collapse)
window.matchMedia polyfill (Ant Design responsive)
Common Commands
cd apps/web
pnpm dev # Start dev server (port 5173)
pnpm build # Production build
pnpm preview # Preview production build
pnpm test # Vitest (30 suites)
pnpm exec vitest # Watch mode
pnpm lint # ESLint