Super Agent · Deep dive

Kỹ thuật chi tiết — Super Agent Portal

Đi sâu vào cơ chế thực thi của cổng Siêu Đại Lý: bản chất là một fork của Agent Portal (CRA React 18), bổ sung tầng quản trị Agent, phân phối credits xuống agent, game mapping per-agent, cây phân cấp 2 cấp và Service Worker (PWA). Đọc sau báo cáo tổng quan 05-superagent.html.

CRA + Craco 7 TypeScript 4.9 React Router v6 Axios + JWT Workbox PWA

Stack & tư duy tổng thể

Trước khi đọc code, nắm 6 đặc điểm "lạ" nhất của app này. Phần lớn xuất phát từ một sự thật: repo này là bản sao của Agent Portal, được mở rộng thêm chứ không viết lại từ đầu — rất nhiều tên gọi trong code vẫn là "Agent".

🧬

App là 1 fork của Agent Portal

package.json vẫn ghi "name": "kioskgaming-agent", README.md ghi "Agent Portal", và toàn bộ type/hook dùng tiền tố Agent* (AgentProfile, useAgentTransactions…). Super Agent = Agent + tầng quản trị agent.

🔌

Dùng axios, không phải fetch

Khác hẳn Kiosk Frontend. Một singleton client trong services/api.ts với interceptor gắn Bearer token và bắt 401. timeout: 15000.

🌐

Routing thật bằng React Router v6

Không phải state machine như Kiosk. Có BrowserRouter, <Routes>, deep-link, ProtectedRoute bọc mọi route trừ /login.

🚪

401 = logout, KHÔNG refresh

Login trả cả refreshToken nhưng app không lưu cũng không dùng. Gặp 401 → bắn event auth:unauthorizedAuthProvider xoá localStorage. Hết hạn = đăng nhập lại.

📱

PWA: repo duy nhất có Service Worker

service-worker.js + Workbox. Chỉ đăng ký ở production (NODE_ENV==='production'). Hỗ trợ offline, precache, image cache.

🎨

Tailwind v3 + theme cam

tailwind.config.js (HSL CSS vars). UI thực tế dùng tông cam (orange-500/600), không phải vàng — màu vàng chỉ là theme của tài liệu này.

💡
Đọc theo thứ tự nào?

Người mới nên mở: src/App.tsx (routes) → src/services/api.ts (toàn bộ endpoint) → src/hooks/useAuth.tsx (auth) → src/pages/SuperAgentManagementPage.tsxManagedAgentDetailPage.tsx (2 trang độc quyền SA). 5 file này là ~75% phần "khác biệt so với Agent".

Cấu trúc thư mục src/

Cây CRA chuẩn: pages/ cho route component, components/ cho UI con, services/api.ts tập trung mọi HTTP. Các file ★ là phần độc quyền của Super Agent.

src/ ├── App.tsx # ★ Khai báo router + ProtectedRoute ├── index.tsx # StrictMode + serviceWorkerRegistration.register() ├── pages/ │ ├── SuperAgentManagementPage.tsx # ★ 3 section: agents | shops | players │ ├── ManagedAgentDetailPage.tsx # ★ 4 tab: info/games/shops/players │ ├── DashboardPage.tsx # stat cards + HierarchyMiniTree │ ├── AgentTransactionsPage.tsx # lịch sử ví (dùng chung tên Agent) │ ├── /createManagedAgentCashCredits — POST /super-agent/agents/:id/cash-credits.tsx # mua credits từ platform (0x/crypto) │ ├── AgentPaymentReturnPage.tsx # trang trả về sau thanh toán │ ├── ShopsPage / ShopDetailPage # dùng /agent/shops (KHÔNG mount trong routes) │ └── SettingsPage, SendBroadcastPage… # legacy / chưa nối route ├── components/ │ ├── auth/ LoginPage.tsx, ProtectedRoute.tsx │ ├── layout/ AppLayout.tsx # sidebar + topbar + bottom nav (mobile) │ ├── credits/ │ │ ├── SuperAgentAgentCreditTrigger.tsx # ★ cấp credits SA→Agent │ │ ├── CreditPurchaseTrigger / Modal # mua credits từ platform │ │ └── SellCreditsCashToAgentTrigger.tsx # cấp credits xuống shop (dùng chung) │ └── dashboard/ HierarchyMiniTree.tsx # ★ cây 2 cấp SA→Agent→Shop ├── services/ api.ts # ★ TẤT CẢ endpoint (axios) ├── hooks/ useAuth.tsx # AuthContext (login/logout/restore) ├── types/ index.ts # SuperManagedAgent/Shop/Player + Agent* ├── constants/ index.ts # STORAGE_KEYS, API_BASE_URL ├── config/ env.ts # ENV (REACT_APP_*) ├── utils/ # costFormat, agentWalletLedger, sanitizeUser… ├── service-worker.js # ★ Workbox (precache + image SWR) └── serviceWorkerRegistration.ts # đăng ký SW (prod only)
🏷️
Tên "Agent" ở khắp nơi là cố ý (di sản fork)

Đừng nhầm: AgentProfile, AgentDashboardData, AgentTransactionsPage, fetchAgentTransactions… đều phục vụ Super Agent. Type SuperManagedAgent/Shop/Player mới là "agent/shop/player mà SA quản lý".

Màn hình khởi động & đăng ký Service Worker

Điều gì xảy ra khi trình duyệt mở app. Khác Kiosk Frontend, đây là CRA SPA thuần client.

1
index.tsx render <App/> trong StrictMode

Mount React 18 createRoot; rồi gọi serviceWorkerRegistration.register() ở cuối file.

2
App.tsx dựng cây Provider + Router

<AuthProvider><BrowserRouter><Routes> + <Toaster/> (react-hot-toast).

3
AuthProvider khôi phục phiên

Đọc agent_auth_token & agent_user_data từ localStorage; nếu hợp lệ → isAuthenticated=true. Trong khi đọc, isLoading=true.

Điều hướng theo route

/ redirect /dashboard. Chưa đăng nhập → ProtectedRoute đẩy về /login (giữ state.from để quay lại).

src/index.tsxtsx
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<React.StrictMode><App /></React.StrictMode>);

// SW chỉ thực sự register khi NODE_ENV === 'production'
serviceWorkerRegistration.register();
🧭
Vì sao SA là SPA client còn Kiosk là state machine?

Kiosk là màn hình chạm 1 phiên, không cần URL. Super Agent là công cụ quản trị nhiều trang, cần deep-link, back/forward, bookmark — nên dùng router thật. Mỗi vai trò chọn mô hình phù hợp.

Hệ thống phân cấp SuperAgent → Agent → Shop → Player

Đây là khái niệm trung tâm. Super Agent đứng đỉnh chuỗi B2B: mua credits từ platform, tạo & quản lý nhiều Agent, mỗi Agent quản nhiều Shop, mỗi Shop có nhiều Player.

Platform Super Agent Agent Shop Player
👑

SA làm được gì (qua /super-agent/*)

  • Tạo / Suspend / Unsuspend / Terminate Agent
  • Sửa cost_rate của Agent
  • Cấp credits cho Agent (debit ví SA)
  • Cấu hình game provider per-Agent
  • Move Shop sang Agent khác, Move Player sang Shop khác
  • Xem toàn bộ agents/shops/players + thống kê
🔁

"Đồng dạng" với cặp Agent→Shop

Quan hệ SA→Agent là bản sao quan hệ Agent→Shop một cấp lên trên: cùng công thức cash = credits × cost_rate, cùng kiểu vòng đời active/suspended/terminated, cùng mô hình "ví cha nạp xuống ví con".

CấpType trong codeNguồn dữ liệu
Super Agent (mình)AgentProfilelogin + /super-agent/dashboard
Agent (con)SuperManagedAgentGET /super-agent/agents
Shop (cháu)SuperManagedShopGET /super-agent/shops (kèm agent)
Player (chắt)SuperManagedPlayerGET /super-agent/players (kèm shop_name, agent_name)
📐
Quan hệ "tham chiếu phẳng"

API không trả cây lồng nhau. SA fetch 3 danh sách phẳng rồi tự nối ở client: shop.agent?.id nối shop với agent, player.shop_id/player.agent_id nối player với shop/agent. HierarchyMiniTree group lại bằng useMemo.

Routing & ProtectedRoute

React Router v6 trong App.tsx. Mọi route (trừ /login) bọc trong ProtectedRoute; route không khớp redirect về /dashboard.

PathComponentGhi chú
/loginLoginPagePublic; có Turnstile captcha (tuỳ env)
/Navigate/dashboard
/dashboardDashboardPageStat cards + HierarchyMiniTree
/agentsSuperAgentManagementPage section="agents"SA only danh sách + tạo agent
/agents/:agentIdManagedAgentDetailPageSA only chi tiết, 4 tab
/shopsSuperAgentManagementPage section="shops"Tất cả shop + move
/playersSuperAgentManagementPage section="players"Tất cả player + move
/agents/newCreateManagedAgentPageTạo managed agent
/online-transactionsOnlineBalanceTransactionsPageLedger USD + rút crypto
/cash-transactionsCashTransactionsLedgerPageGiao dịch tiền mặt
/payment/returnAgentPaymentReturnPageCallback mua credit online
/transactionsAgentTransactionsPageLịch sử ví SA
/payment/returnAgentPaymentReturnPageTrang quay về sau cổng thanh toán
*Navigate/dashboard
src/components/auth/ProtectedRoute.tsxtsx
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <PageLoadingIndicator variant="section" />;
if (!isAuthenticated)
  return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>;
⚠️
"SA only" chỉ là phân biệt UI, không phải phân quyền route

ProtectedRoute chỉ kiểm tra "đã đăng nhập chưa", không kiểm tra vai trò. Việc một route là "độc quyền SA" được đảm bảo bởi backend qua prefix /super-agent/* + token SA. Frontend không có guard theo role.

services/api.ts — trung tâm gọi API

Một file duy nhất chứa toàn bộ hàm gọi backend, export theo từng nghiệp vụ. Component import hàm cần dùng, tự quản loading/error.

PATTERNAxios singleton + request/response interceptor
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api' (prefix /api nằm sẵn trong base)
  • Request: đọc agent_auth_token từ localStorage → thêm Authorization: Bearer <token>
  • Response 401: window.dispatchEvent(new Event('auth:unauthorized')) rồi reject — KHÔNG refresh
  • Timeout: 15000ms
  • Envelope: response chuẩn { success, data?, message? }
src/services/api.ts (rút gọn)ts
const client = axios.create({ baseURL: API_BASE_URL, timeout: 15000 });

client.interceptors.request.use((config) => {
  const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

client.interceptors.response.use((r) => r, (error) => {
  if (error.response?.status === 401)
    window.dispatchEvent(new Event('auth:unauthorized'));
  return Promise.reject(error);
});
🚨
App vẫn gọi nhiều endpoint /agent/* (không phải /super-agent/*)

Vì là fork, các tính năng kế thừa vẫn gọi prefix cũ: fetchShops()/agent/shops, fetchAgentPlayers()/agent/players, broadcasts/in-app-popups → /agent/broadcasts, /agent/in-app-popups/*, low-balance → /agent/settings/low-balance-warning, cấp credits xuống shop → POST /agent/credits/distribute. Backend phải chấp nhận token SA cho cả 2 prefix. Khi debug 403/404, luôn kiểm tra prefix thực của hàm.

Luồng đăng nhập & JWT

Đăng nhập 1 bước email + password (kèm captcha tuỳ chọn). Token lưu localStorage. Không có mã OTP như Kiosk, không có refresh token.

1
Submit form login

loginWithEmailPassword(email, password, captchaToken?)POST /super-agent-auth/login. Nếu REACT_APP_TURNSTILE_ENABLED bật thì bắt buộc có Cloudflare Turnstile token.

2
Đọc profile linh hoạt

Lấy res.data.agent hoặc res.data.superAgent (backend trả khác key tuỳ phiên bản), chuẩn hoá qua toAgentProfile() (loại bỏ passwordHash).

3
Lưu phiên

login(profile, accessToken) ghi agent_auth_token + agent_user_data vào localStorage; bắn agent-auth-token-changed. refreshToken bị bỏ qua.

4
Điều hướng

navigate(from) — quay lại trang đã chặn trước đó (qua location.state.from) hoặc /dashboard.

🔑

Lưu & khôi phục

localStorage: agent_auth_token, agent_user_data. Khi reload, AuthProvider đọc lại; JSON lỗi → tự logout().

🚪

Logout = thuần client

logout() chỉ xoá localStorage + reset state, không gọi API. Cũng được trigger tự động khi nhận event auth:unauthorized (401).

🐛
2 điểm dễ vấp về auth

(1) Không có refresh token → access token hết hạn là user bị đá ra login ngay (mọi request đang chạy fail 401). (2) STORAGE_KEYS dùng chung tên với Agent Portal (agent_auth_token). Nếu chạy SA và Agent app trên cùng origin (cùng host:port) chúng sẽ ghi đè token của nhau. App tránh điều này bằng cách dùng port khác (SA mặc định 3003).

Quản lý state

Tối giản: chỉ 1 Context (Auth). Mọi dữ liệu nghiệp vụ là local state trong từng page, tải bằng Promise.all khi mount.

Có gì
  • AuthContext (useAuth): user, token, isAuthenticated, isLoading
  • Local state mỗi page: agents/shops/players, loading, modal, search/filter
  • react-hook-form cho form login
  • react-hot-toast cho thông báo (thay cho notificationService)
  • useMemo cho filter/group (filteredAgents, shopsByAgent…)
Không có
  • Redux / Zustand / Jotai
  • React Query / SWR (mọi fetch tự viết tay)
  • Cache cross-page — mỗi page tự fetch lại
  • Optimistic update — sau mutate luôn gọi lại load()
Mẫu tải dữ liệu song song (SuperAgentManagementPage)tsx
const [agentsRes, shopsRes, playersRes] = await Promise.all([
  fetchManagedAgents(),   // GET /super-agent/agents
  fetchManagedShops(),    // GET /super-agent/shops
  fetchManagedPlayers()    // GET /super-agent/players
]);
setAgents(agentsRes.data ?? []); /* …setShops, setPlayers */

SuperAgentManagementPage — 3 section trong 1 component

Một component dùng cho 3 route (/agents, /shops, /players) qua prop section. Lưu ý: đây là 3 route riêng, không phải tab — chuyển section = đổi URL, component re-mount & fetch lại cả 3 danh sách.

sectionHành động chínhSearch / Filter
agentsTạo agent (createManagedAgent), Suspend/Unsuspend, Terminate, link vào chi tiếtSearch name/email/phone → filteredAgents
shopsMove shop sang agent khácSearch + filter theo agent → filteredShops
playersMove player sang shop khácSearch + filter theo shop → filteredPlayers

Tạo Agent — validation phía client

SuperAgentManagementPage.tsxtsx
const PASSWORD_MIN = 8;
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;

// % nhập vào (0–100) → rate thập phân (0–1), làm tròn 4 chữ số
const toRate = (percent: string) =>
  Math.min(1, Number((Number(percent) / 100).toFixed(4)));

createManagedAgent({ name, email, phone, password,
  cost_rate: toRate(form.costPercent),
  can_create_shop_popup, can_manage_shop_popup_permission,
  default_shop_can_create_player_popup });
📞

Phone & popup permissions

Input điện thoại dùng react-phone-number-input (mặc định US). 3 checkbox uỷ quyền popup/broadcast cho agent: can_create_shop_popup, can_manage_shop_popup_permission, default_shop_can_create_player_popup.

⌨️

Terminate cần gõ xác nhận

window.prompt('Type "terminate"…'); phải gõ đúng chữ terminate mới gọi API. Đây là cơ chế chống nhầm, không phải modal đẹp.

Move Shop / Move Player

Move Shop

PATCH /super-agent/shops/{id}/move body { target_agent_id }. Dropdown disable agent hiện tại của shop để không "move vào chính nó".

Move Player

PATCH /super-agent/players/{id}/move body { target_shop_id }. Dropdown hiện tất cả shop kèm tên agent chủ để có ngữ cảnh; disable shop hiện tại.

🔧
HTTP verb thực tế là PATCH (không phải PUT/POST)

Tài liệu tổng quan ghi PUT/POST cho suspend/terminate/move, nhưng code dùng PATCH cho suspend/unsuspend/terminate/rates/move. Riêng tạo agent & cấp credits dùng POST; game-mappings dùng PUT. Luôn lấy verb từ api.ts.

ManagedAgentDetailPage — 4 tab điều khiển bằng URL

Route /agents/:agentId. Tab lưu trong query ?tab= (giữ được khi F5). Khác hẳn management page (dùng route), đây là tab thật trong cùng URL.

info · games · shops · players
ManagedAgentDetailPage.tsxtsx
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = isAgentDetailTab(searchParams.get('tab'))
  ? searchParams.get('tab') : 'info';

// info là default → xoá param 'tab' khỏi URL cho gọn
const setActiveTab = (t) => setSearchParams((p) => {
  const n = new URLSearchParams(p);
  t === 'info' ? n.delete('tab') : n.set('tab', t);
  return n;
}, { replace: true });
ℹ️

Tab Info

Status badge, credit_balance, nút Credit (SuperAgentAgentCreditTrigger), Suspend/Terminate, và sửa cost rate inline. Gợi ý "phải cao hơn rate của SA" hiển thị từ superAgentProfile.costRate.

🎮

Tab Games

Tải song song fetchManagedAgentGameMappings + fetchPublicGameProviders. Chọn All hoặc Selected. Xem mục 10.

🏪

Tab Shops

Lọc client: shops.filter(s => s.agent?.id === agentId). Hiện total/active + bảng shop.

👤

Tab Players

Lọc client: players.filter(p => p.agent_id === agentId). Hiện total/active + bảng player.

🐢
Không có endpoint "1 agent" — fetch cả 3 list rồi lọc

load() gọi fetchManagedAgents/Shops/Players (toàn bộ) rồi .find()/.filter() theo agentId ở client. Với hệ thống lớn đây là điểm tốn băng thông; nếu cần tối ưu, đề xuất backend thêm GET /super-agent/agents/:id.

Cấu hình Game Provider theo từng Agent

SA giới hạn tập game mỗi agent được phép dùng. Logic kế thừa: agent chỉ thấy game nằm trong "pool" của SA; lưu mảng rỗng = "kế thừa toàn bộ".

1
Tải mapping + danh mục game

Promise.all([fetchManagedAgentGameMappings(agentId), fetchPublicGameProviders()]). Mapping trả game_ids (của agent) + super_agent_game_ids (pool SA).

2
Lọc theo pool SA

visibleGameProviders: nếu parentGamePool.length > 0 chỉ hiện game thuộc pool đó; nếu pool rỗng → hiện mọi provider status !== 'maintenance'.

3
Chọn mode

game_ids rỗng → mode All (kế thừa pool SA); có phần tử → mode Selected với checkbox.

4
Lưu

putManagedAgentGameMappings(agentId, payload) với payload = mode==='all' ? [] : selectedGameIds.

MethodEndpointMục đích
GET/super-agent/agents/{id}/game-mappingsTrả { game_ids, super_agent_game_ids }
PUT/super-agent/agents/{id}/game-mappingsBody { game_ids }[] = kế thừa SA
GET/game-providersDanh mục public, response { data: { items[] } }
🎯
Endpoint danh mục là /game-providers

Không phải /public/game-providers như tài liệu tổng quan. Dữ liệu nằm trong data.items[] (mỗi item: { id, name, code, status? }), được lọc bỏ status === 'maintenance' trước khi render.

Phân phối Credits

Hai dòng tiền: (1) SA mua credits từ platform; (2) SA cấp credits xuống Agent. Cấp xuống Shop dùng lại component của Agent Portal.

Platform → mua → SA wallet → distribute → Agent wallet → distribute → Shop Player

SA → Agent: SuperAgentAgentCreditTrigger

Component đặt ở tab Info của chi tiết agent. Mở modal → tải số dư SA (fetchDashboard) → nhập amount → preview cash → submit.

components/credits/SuperAgentAgentCreditTrigger.tsxtsx
// Preview: cash mà agent phải trả cho SA
const effectiveCostRate = Math.min(1, Math.max(0, Number(agentCostRate ?? 0)));
const cashToCollect = credits * effectiveCostRate;
// vd: 100 credits × 0.95 = $95.00 (agent trả SA)

await distributeCreditsToManagedAgent(agentId, amount);
// POST /super-agent/agents/{id}/cash-credits — bán credit cho agent (cash)
💰

SA mua credits từ platform

purchaseCredits(amount, paymentMethod?)POST /super-agent/credits/purchase. /createManagedAgentCashCredits — POST /super-agent/agents/:id/cash-credits hỗ trợ phương thức 0x/crypto (LinkMePay), trả paymentUrl để redirect.

🏪

Cấp credits xuống Shop (dùng chung)

distributeCredits(shopId, amount)POST /agent/credits/distribute body { shop_id, amount } — đây là endpoint của Agent, không phải super-agent.

🔗
Endpoint distribute là /credits/distribute

createManagedAgentCashCredits gọi POST /super-agent/agents/{id}/cash-credits. WithdrawCreditsFromAgentTrigger thu hồi credit. Công thức: cash = credits × agent_cost_rate.

HierarchyMiniTree — cây 2 cấp + thống kê

Khác Agent Portal (1 cấp Agent→Shop), SA thấy 2 cấp: SA → Agents → Shops, kèm thống kê deposit/withdrawal/users theo khoảng ngày.

components/dashboard/HierarchyMiniTree.tsxtsx
type Props = { superAgentName: string;
  agents: SuperManagedAgent[]; shops: SuperManagedShop[] };

// Group shop theo agent (Map<agentId, shops[]>), sort theo tên
const shopsByAgent = useMemo(() => { /* ... */ }, [shops]);

// Thống kê theo kỳ (mặc định 1 tháng gần nhất)
fetchSuperAgentShopHierarchyStats({ from, to });
// GET /super-agent/shops/stats?from=&to=
🌳

Tương tác

Root SA expand/collapse; mỗi agent expand riêng để xem shop dạng card grid; StatusDot mỗi node (active/suspended/terminated); shop card link tới /agents/{agentId}.

📊

Thống kê

Date range from/to (mặc định 30 ngày). Summary row tổng deposit/withdrawal/new users/total users; mỗi shop có stats riêng từ stats.shops[] map theo id.

📅
Endpoint stats là /super-agent/shops/stats

Không phải /super-agent/shop-hierarchy-stats. Response: { range, summary, shops[] }; mỗi shop có agent_id, agent_name, total_users, new_users, total_deposit, total_withdrawal.

Service Worker (Workbox)

Repo duy nhất trong hệ sinh thái có SW — phù hợp vai trò "regional manager" cần dùng trên tablet/mobile. Chỉ chạy ở production.

src/service-worker.jsjs
clientsClaim();                          // SW mới claim ngay, không chờ
precacheAndRoute(self.__WB_MANIFEST);   // precache asset build-time

// SPA: navigate request → trả index.html cho React Router
registerRoute(isNavigate,
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'));

// Ảnh .png cùng origin → StaleWhileRevalidate, tối đa 100 entry
registerRoute(isPng, new StaleWhileRevalidate({
  cacheName: 'images',
  plugins: [new ExpirationPlugin({ maxEntries: 100 })] }));

Precache

__WB_MANIFEST được Workbox inject lúc build; cache JS/CSS/font tĩnh.

SPA fallback

Navigate (không phải file, không bắt đầu /_) → index.html.

SKIP_WAITING

Lắng nghe message SKIP_WAITINGself.skipWaiting() để cập nhật nhanh.

🧪
Dev không thấy SW hoạt động

serviceWorkerRegistration.register() chỉ chạy khi process.env.NODE_ENV === 'production'. Chạy npm start (dev) sẽ không đăng ký SW — muốn test PWA phải npm run build rồi serve thư mục build/.

Config & biến môi trường

Tập trung ở src/config/env.ts + src/constants/index.ts. Mọi biến phải có tiền tố REACT_APP_ (quy ước CRA) mới dùng được ở client.

BiếnMặc địnhÝ nghĩa
REACT_APP_API_BASE_URLhttp://localhost:3001/apiBase URL backend (đã gồm /api) — chung backend với admin
REACT_APP_APP_NAMEKiosk Gaming — AgentTên app (di sản fork, vẫn ghi "Agent")
REACT_APP_TURNSTILE_ENABLEDfalseBật Cloudflare Turnstile ở màn login
REACT_APP_TURNSTILE_SITE_KEYSite key Turnstile
PORT3003Tránh đụng admin (3000) — quan trọng để không clash localStorage token
📄
Có sẵn .env.example

Copy .env.example.env. Backend cần set AGENT_FRONTEND_URL trỏ về app này (vd http://localhost:3003) để redirect thanh toán quay về đúng /payment/return. CORS: thêm hostname vào bảng domains (type admin) hoặc DISABLE_CORS=true khi dev.

API Endpoints tổng hợp (theo code thật)

Trích từ services/api.ts. Chú ý prefix kép: /super-agent/* (tính năng SA) xen lẫn /agent/* (kế thừa từ fork).

MethodEndpointHàmMục đích
POST/super-agent-auth/loginloginWithEmailPasswordĐăng nhập SA
GET/super-agent/dashboardfetchDashboardSố dư + đếm shop/agent
GET/super-agent/agentsfetchManagedAgentsDanh sách agent
POST/super-agent/agentscreateManagedAgentTạo agent
PATCH/super-agent/agents/{id}/suspendsuspendManagedAgentĐình chỉ
PATCH/super-agent/agents/{id}/unsuspendunsuspendManagedAgentBỏ đình chỉ
PATCH/super-agent/agents/{id}/terminateterminateManagedAgentChấm dứt ({confirm:true})
PATCH/super-agent/agents/{id}/ratesupdateManagedAgentRateSửa cost_rate (số nhiều: rates)
POST/super-agent/agents/{id}/cash-creditscreateManagedAgentCashCreditsBán credit SA→Agent (cash)
POST/super-agent/agents/{id}/credits/withdrawwithdrawCreditsFromManagedAgentThu hồi credit
GET/super-agent/agents/{id}/game-mappingsfetchManagedAgentGameMappingsLấy game của agent
PUT/super-agent/agents/{id}/game-mappingsputManagedAgentGameMappingsLưu game ({game_ids})
GET/super-agent/shopsfetchManagedShopsTất cả shop
PATCH/super-agent/shops/{id}/movemoveManagedShopMove shop ({target_agent_id})
GET/super-agent/shops/statsfetchSuperAgentShopHierarchyStatsThống kê hierarchy
GET/super-agent/playersfetchManagedPlayersTất cả player
PATCH/super-agent/players/{id}/movemoveManagedPlayerMove player ({target_shop_id})
POST/super-agent/credits/purchasepurchaseCreditsMua credits từ platform
GET/super-agent/transactionsfetchAgentTransactionsLịch sử ví SA
GET/game-providersfetchPublicGameProvidersDanh mục game public
POST/agent/credits/distribute /agent/*distributeCreditsCấp credits xuống shop (kế thừa)
GET/agent/shops, /agent/players /agent/*fetchShops, fetchAgentPlayersDùng bởi ShopsPage/ShopDetailPage
PATCH/agent/settings/low-balance-warning /agent/*patchAgentLowBalanceWarningCảnh báo số dư thấp
GET/agent/broadcasts, /agent/in-app-popups/* /agent/*broadcasts / popupsThông báo (kế thừa)

Cookbook cho dev mới

Các tác vụ phổ biến và cách làm đúng theo quy ước project.

HOW-TOThêm một endpoint backend mới
  1. Thêm hàm export vào src/services/api.ts, gọi client.get/post/patch/put(...) (token tự gắn).
  2. Khai báo kiểu response trong src/types/index.ts (theo envelope { success, data?, message? }).
  3. Component import hàm, tự quản loading/error bằng useState; báo lỗi qua toast.
HOW-TOThêm một route mới
  1. Tạo page trong src/pages/, bọc nội dung trong <AppLayout>.
  2. Khai báo <Route> trong App.tsx, bọc <ProtectedRoute> nếu cần đăng nhập.
  3. Thêm link vào NAV_ITEMS trong components/layout/AppLayout.tsx (đặt primary:true nếu muốn hiện ở bottom nav mobile).
HOW-TOThêm tab vào ManagedAgentDetailPage
  1. Thêm giá trị vào mảng AGENT_TABS + cập nhật isAgentDetailTab().
  2. Thêm nút tab (gọi setActiveTab) và khối render {activeTab === '...' && (...)}.
  3. Nếu tab cần fetch riêng, dùng useEffect phụ thuộc [activeTab, agentId] + cờ cancelled để tránh race.
HOW-TOLàm việc với cost rate
  1. Backend lưu rate thập phân 0–1 (0.05 = 5%). UI nhập %.
  2. % → rate: toRate() / percentInputToRate() (clamp 0–100, /100, toFixed(4)).
  3. rate → %: toPercent() (×100, toFixed(2)) hoặc formatCostRate() (Intl.NumberFormat style:'percent').

Bẫy thường gặp (đọc trước khi sửa)

BẫyChi tiết & cách xử lý
Tưởng đây là app riêng biệtfork của Agent Portal: package.json name "kioskgaming-agent", README ghi "Agent". Type/hook tiền tố Agent* phục vụ SA.
Clash localStorage tokenSTORAGE_KEYS = agent_auth_token/agent_user_data dùng chung với Agent app. Chạy cùng origin sẽ ghi đè nhau — tách port (SA 3003).
Trông đợi refresh tokenLogin trả refreshToken nhưng app KHÔNG lưu/dùng. 401 = logout ngay. Không có cơ chế gia hạn.
Sai HTTP verbsuspend/unsuspend/terminate/rates/move dùng PATCH; create & credits dùng POST; game-mappings dùng PUT. Lấy verb từ api.ts, đừng tin bảng tổng quan.
Sai tên endpointThực tế: /super-agent/shops/stats, /super-agent/agents/{id}/rates, .../credits/distribute, danh mục game /game-providers.
Prefix kép /agent vs /super-agentNhiều tính năng kế thừa vẫn gọi /agent/* (shops, players, broadcasts, popups, credits→shop, settings). Backend phải nhận token SA cho cả hai.
Chi tiết agent tải chậmManagedAgentDetailPage fetch toàn bộ 3 list rồi lọc client. Không có GET /super-agent/agents/:id.
Section ≠ tab/agents /shops /players là 3 route khác nhau (cùng component), KHÔNG phải tab. Chỉ chi tiết agent mới có tab (?tab=).
SW không chạy khi devSW chỉ register ở production. Muốn test PWA phải npm run build + serve build/.
Cost rate đơn vịBackend = thập phân 0–1; UI = %. Luôn dùng toRate/toPercent. Nhập 5 → lưu 0.0500.
Login profile keyĐọc res.data.agent HOẶC res.data.superAgent — backend trả khác key tuỳ phiên bản.
Captcha bật/tắt theo envTurnstile chỉ bắt buộc khi REACT_APP_TURNSTILE_ENABLED='true' & có site key. Quên cấu hình sẽ chặn login khi bật.