This guide covers the client application’s architecture, patterns, and conventions for developers adding or modifying frontend features.
Tech Stack
| Tool | Version/Role |
|---|---|
| React 18 | UI framework |
| TypeScript | Type safety |
| Vite | Build tool and dev server |
| React Router v7 | Client-side routing |
| Zustand | Global state management |
| Socket.IO Client | WebSocket communication |
| Tailwind CSS v4 | Utility-first styling |
| i18next | Internationalisation |
@poker/shared | Shared package (stores, types, utilities) |
The client source lives in /client/src/. The React Native mobile app is a separate package under packages/mobile/.
Directory Structure
client/src/
api/ — REST API client functions
components/ — Shared UI components
game/ — Game-specific components (table, cards, chips, HUD)
admin/ — Admin panel components
agent/ — Agent portal components
config/ — Client-side config (request retry settings)
constants/ — Animation durations, agent permissions
contexts/ — React contexts (SocketContext)
hooks/ — Custom hooks
layouts/ — Layout wrappers (MainLayout, AdminLayout, AuthLayout, AgentLayout)
lib/ — Utilities: socket type defs, game state mapper, etc.
pages/ — Page components (one per route)
admin/ — Admin portal pages
auth/ — Login, register, forgot password, etc.
game/ — Poker table page
lobby/ — Lobby and tournament detail pages
cashier/ — Deposit/withdrawal page
affiliate/ — Affiliate dashboard
agent/ — Agent portal pages
leaderboard/ — Leaderboard page
profile/ — Profile page
routes/ — React Router configuration (index.tsx, lazy-loaders.ts)
store/ — Zustand stores (re-exports from @poker/shared + web-specific wiring)
utils/ — Pure utility functions
main.tsx — Application entry point
App.tsx — Root component with providers
Routing
Routes are defined in client/src/routes/index.tsx using createBrowserRouter from react-router-dom.
Route structure
/ — HomePage (public landing)
/lobby/:tab? — LobbyPage (lazy-loaded)
/tournament/:tournamentId — TournamentDetailPage (lazy-loaded)
/table/:tableId — TablePage (authenticated, fullscreen, no layout wrapper)
/tables/:tableId/watch — SpectatorPage (authenticated)
/profile — ProfilePage (authenticated, lazy)
/cashier — CashierPage (authenticated)
/affiliate — AffiliateDashboardPage (authenticated)
/leaderboard — LeaderboardPage
/pages/:slug — PublicPage (CMS pages)
/ext/:providerType/* — PluginPageRenderer (authenticated plugin pages)
/plugins/:providerType/public/* — PluginPageRenderer (public plugin pages)
/login — LoginPage
/register — RegisterPage
/forgot-password — ForgotPasswordPage
/reset-password — ResetPasswordPage
/verify-email — VerifyEmailPage
/admin/... — Admin panel
/agent/... — Agent portal (authenticated)
Adding a new page
- Create a page component in the appropriate subdirectory under
client/src/pages/. - Add a lazy import at the top of
routes/index.tsx:const MyNewPage = lazy(() => import('@/pages/MyNewPage').then(m => ({ default: m.MyNewPage }))); - Add the route inside the appropriate
childrenarray:{ path: 'my-path', element: <Lazy><MyNewPage /></Lazy> }, - Wrap with
<RequireAuth>if authentication is needed.
Layout wrappers
MainLayout— header, sidebar navigation, footer. Used for most player-facing pages.AuthLayout— centred card layout for auth forms.AdminLayout— admin sidebar + content area.AgentLayout— agent portal sidebar + content area.
The table page intentionally has no layout wrapper — it renders fullscreen.
Zustand Store Architecture
Zustand is used for global state. Most stores live in @poker/shared (the shared package) and are re-exported from client/src/store/ with web-specific wiring (localStorage adapters, cross-store reset hooks).
Available stores
| Store | Purpose |
|---|---|
useAuthStore | Authenticated user, tokens, login/logout |
useGameStore | Current table state (players, cards, pot, actions) |
useLobbyStore | Available tables, tournaments, filters |
useChipAnimationStore | Chip animation state (pots, winners) |
useToastStore | Toast notification queue |
useSettingsStore | Admin settings (read-only cache) |
useUserSettingsStore | Per-user preferences (sound, language, etc.) |
useTableSessionStore | Per-table session data (last buy-in, etc.) |
useI18nStore | Language preference |
useConfirmStore | Confirmation dialog state |
useWageringProgressStore | Bonus wagering progress |
Accessing state
Always use selector functions to avoid unnecessary re-renders:
// Specific selector — only re-renders when player's stack changes
const myStack = useGameStore(state => state.players.find(p => p.id === myId)?.stack);
// Pre-built selectors from @poker/shared
import { useGameStore, selectMyPlayer, selectIsMyTurn } from '@/store';
const me = useGameStore(selectMyPlayer);
const isMyTurn = useGameStore(selectIsMyTurn);
Accessing state outside React
const state = useGameStore.getState();
const player = state.players.find(p => p.id === 'abc');
Auth store wiring
The auth store is created with platform-specific adapters:
// client/src/store/authStore.ts
export const useAuthStore = createAuthStore({
storage: createWebStorageAdapter(), // localStorage
onLogin: () => {
useUserSettingsStore.getState().loadSettings();
},
onLogout: () => {
useUserSettingsStore.getState().clearSettings();
useGameStore.getState().resetGame();
useLobbyStore.getState().resetLobby();
useWageringProgressStore.getState().clearProgress();
},
});
Cross-store cleanup on logout is wired here — add any new store resets to the onLogout callback.
Custom Hooks
useSocket()
Thin wrapper around SocketContext. Returns:
{
socket: PokerSocket | null;
isConnected: boolean;
userId: string | null;
sessionId: string | null;
error: string | null;
connect: () => void;
disconnect: () => void;
}
The SocketContext (client/src/contexts/SocketContext.tsx) manages the Socket.IO connection lifecycle. Hooks that need to subscribe to events should use useSocketContext() directly to access subscribe(event, handler) and emit(event, data).
useGameSocket(options)
The primary hook for game table pages. Manages all game-related WebSocket events:
const {
isConnected,
isJoined,
error,
remoteStyleId,
joinTable, // (seatIndex, buyIn, password?) => Promise<boolean>
leaveTable, // (keepWatching?) => Promise<{success, cashOut?, error?}>
sendAction, // (action, amount?) => Promise<boolean>
sitOut,
sitIn,
useTimeBank,
addChips, // (amount) => Promise<boolean>
tournamentRebuy,
tournamentAddOn,
tournamentReentry,
sendChat, // (message) => void
} = useGameSocket({ tableId, seatIndex?, buyIn?, password?, autoJoin? });
The hook:
- Subscribes to the server’s table and hand events (
table_state,hole_cards,community_cards,forced_bets, and others). - Maps server state (
TableState) to store state, usingmapGameState()for the phase andmapTablePlayer()for players. - Handles showdown display (default duration 4s).
- Detects reconnections and re-requests table state.
- Sequences events using
lastSequenceRefto handle out-of-order delivery.
useLobbySocket()
Emits subscribe_lobby when connected and keeps useLobbyStore up to date from lobby WebSocket events.
useSpectatorSocket(options)
Read-only counterpart of useGameSocket: returns isWatching, tableState, error, watchTable() and stopWatching() without seating the user. Used by the spectator view.
useHandTransition()
Orchestrates the timeline between hands. It lives in @poker/shared and is used by web and mobile:
hand_result→ WINNER phase: badges and payout animation.- CLEANUP phase: community and hole cards exit (
cleanupPhaseMs, default 600ms). - IDLE: showdown state cleared and the pot reset.
Events such as game_started that arrive during a transition are deferred until IDLE, which prevents game state flickers between hands.
usePlayerControls()
Returns the set of available actions and bet sizing UI state for the current player’s turn. Wraps selectAvailableActions with bet slider logic.
useIsMobile()
Returns true when the viewport is below the breakpoint (useIsMobile(breakpoint = 640)). Used for responsive layout decisions.
usePermission(feature)
Checks if the authenticated user has access to an admin feature (for example tables:read). Used to conditionally render admin controls. usePermissions(features) and useAnyPermission(features) check several.
WebSocket Integration
All WebSocket communication is typed via client/src/lib/socket.ts. The PokerSocket type extends Socket.IO’s Socket with typed event maps.
Event flow for game actions
User clicks "Bet"
→ sendAction('bet', 500) (useGameSocket)
→ socket.emit('player_action', ...) (SocketContext.emit)
→ server validates and applies action
→ server emits 'table_state' to the table's sockets
→ useGameSocket receives 'table_state'
→ server state is mapped to store format
→ useGameStore updated
→ React components re-render
Game state mapping
client/src/lib/gameStateMapper.ts holds the helpers that convert server state to store format:
parseCard()— converts a card string to aCardobject.mapGameState()— maps the server’s game state string to aGamePhase.preserveRevealedCards()— retains card face-up state across state updates to prevent cards flickering back to face-down.
Tailwind CSS Conventions
The project uses Tailwind CSS v4. There is no tailwind.config.js; the theme is defined with @theme in client/src/index.css.
Class conventions
- Use Tailwind utilities directly on components.
- Prefer responsive variants (
sm:,md:,lg:) over CSS media queries. - Do not use CSS modules or styled components. Most styling is Tailwind; a few table and chat surfaces have plain CSS files next to their components (for example
client/src/pages/game/V2TablePage.css). - Use logical CSS so right-to-left languages work.
npm run lint:rtl --prefix clientchecks this in CI.
Custom design tokens
| Token | Value / Purpose |
|---|---|
--color-table-green-* | Poker table greens (50–950) |
--color-card-red-* | Red card colours |
--color-card-black-* | Black card colours |
--color-gold-* | Gold accents |
Component Patterns
Page components
Pages are thin wrappers that compose smaller components and connect to stores/hooks:
// client/src/pages/lobby/LobbyPage.tsx
export function LobbyPage() {
const { isConnected } = useLobbySocket();
const tables = selectFilteredTables(useLobbyStore.getState());
return (
<div className="container mx-auto p-4">
{/* compose TableListView, TournamentListView, etc. */}
</div>
);
}
Feature components
Complex features live in client/src/components/ and are reused across pages. The table pages themselves are in client/src/pages/game/ (TablePage.tsx, V2TablePage.tsx, PokerTable.tsx, V2PokerTable.tsx).
components/game/
PlayerSeat.tsx — individual player display
PlayingCard.tsx — single card
PotDisplay.tsx — pot display
ChipAnimation.tsx — chip animations
TimerDisplay.tsx — action timer
controls/ — action controls (PlayerControlsFactory, ClassicControls, DockControls, V2Controls)
v2/ — Table V2 components (V2TableShell, V2PlayerSeat, V2CommunityCards, V2TopBar…)
Loading states
Use the Skeleton component for loading placeholders:
<Skeleton className="h-4 w-32" /> // Inline loading placeholder
Use <RouteLoadingFallback /> as the Suspense fallback for lazy-loaded routes.
Error boundaries
The router’s <Lazy> wrapper puts each lazy route in an ErrorBoundary, so rendering errors are caught and displayed without crashing the whole app.
Internationalisation (i18n)
Translation strings are stored in the database and served by the API at /config/languages/{{lng}}/phrases, loaded through i18next-http-backend. Bundled default packs are in src/data/ (default-phrases-en.json, default-phrases-zh-CN.json). The useTranslation() hook from react-i18next provides access:
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <span>{t('lobby.balance.realMoney', 'Real Money')}</span>;
}
Pass the English text as the second argument so the UI has a fallback. useLocale() returns the current locale; the language preference is managed by useI18nStore.
To add a new translation key:
- Add it to
src/data/default-phrases-en.json. - Add the translation to
src/data/default-phrases-zh-CN.json(npm run i18n:validate:zh-cnreports missing keys). - Use
t('key', 'English text')in components.
Adding an Admin Page
- Create the page in
client/src/pages/admin/:// client/src/pages/admin/MyAdminPage.tsx export function MyAdminPage() { return <div>My admin content</div>; } - Add a lazy import in
routes/index.tsx:const MyAdminPage = lazy(() => import('@/pages/admin/MyAdminPage').then(m => ({ default: m.MyAdminPage }))); - Add the route inside the admin children:
{ path: 'my-page', element: <Lazy><MyAdminPage /></Lazy> }, - Add a sidebar link in
layouts/AdminLayout.tsx, with itslabelKey,defaultLabelandrequiredFeature. - Protect the API route with
requireFeature('feature:scope'), using a key fromADMIN_FEATURESinsrc/api/middleware/auth.ts.
Code Splitting
All admin pages and non-critical player pages are lazy-loaded via React.lazy(). The router wraps them in <Lazy> which uses <Suspense> with <RouteLoadingFallback>.
Avoid importing large admin components in player-facing code paths — this would defeat code splitting.
Important: Do not use NodeJS.Timeout or other @types/node types in client code. The frontend build is isolated and does not include @types/node. Use ReturnType<typeof setTimeout> instead.