Kỹ thuật chi tiết — Agent Portal
Mổ xẻ phần code thật của cổng Đại Lý B2B: tầng api.ts (Axios), hệ thống type quanh ví credit, và quan trọng nhất là kinh tế credit — costRate của agent vs shop quyết định lợi nhuận (commission). Đọc sau báo cáo tổng quan 04-agent.html.
Stack & tư duy tổng thể
App này khác hẳn kiosk frontend (Next.js): đây là một SPA quản trị B2B chạy trên Create React App. Nắm 6 đặc điểm sau trước khi đọc code — chúng giải thích phần lớn cấu trúc.
CRA + Craco, không Next.js
Scripts qua craco start/build/test; craco.config.js gọi packages/ui/scripts/craco-configure-ui.cjs để bundle @kioskgaming/ui. Entry: src/index.tsx → <App/>.
Axios, không fetch thuần
Một AxiosInstance singleton trong services/api.ts với interceptor tự gắn Bearer token và phát sự kiện auth:unauthorized khi gặp 401. baseURL đã bao gồm /api.
App xoay quanh ví credit
Nghiệp vụ cốt lõi: agent mua credit từ platform rồi phân phối xuống shop. Chênh lệch giữa costRate của agent và shop chính là lợi nhuận. Đây là phần đáng đọc nhất.
Chỉ 1 Context: Auth
Không Redux/Zustand/React Query. State toàn cục duy nhất là AuthProvider (hooks/useAuth.tsx). Mỗi page tự fetch dữ liệu của mình bằng useState/useEffect.
Có route protection thật
Khác kiosk frontend, app này có ProtectedRoute bọc mọi trang. Chưa đăng nhập → redirect /login (giữ lại state.from để quay về).
Dùng package nội bộ workspace
Import từ @kioskgaming/ui (Modal, CommonModal, Button, PhoneInput) và @kioskgaming/page-loading — cài qua file:../packages/*. Phải build/symlink packages mới chạy được.
Cấu trúc thư mục src/
Phân lớp rõ: pages/ (route đích) → components/ (theo domain) → services/ + hooks/ + utils/. Hai file trung tâm: services/api.ts và types/index.ts.
Người mới nên mở: App.tsx → services/api.ts → types/index.ts → CreditPurchaseModal.tsx & SellCreditsCashToShopTrigger.tsx (nghiệp vụ tiền).
Bản đồ route & bảo vệ route
App.tsx dùng BrowserRouter + Routes. Mọi route nghiệp vụ đều bọc trong <ProtectedRoute>; chỉ /login là public. Catch-all * và /purchase đều Navigate về /.
| URL | Component | Bảo vệ | Ghi chú |
|---|---|---|---|
/login | LoginPage | public | username/email + password (+Turnstile, OTP 2FA) |
/forgot-password/* | 3 trang reset | public | OTP email/phone → đặt mật khẩu mới |
/ | DashboardPage | protected | Credit + USD balance sidebar |
/transactions | AgentTransactionsPage | protected | Ledger credit + export CSV |
/online-transactions | OnlineBalanceTransactionsPage | protected | Ledger USD + rút crypto |
/cash-transactions | CashTransactionsLedgerPage | protected | Giao dịch tiền mặt shop |
/provider-fees | ProviderFeesPage | protected | Xem phí provider |
/fraud-indicators | FraudIndicatorsPage | protected | Gian lận player |
/shops/:shopId | ShopDetailPageRedesign | protected | Bán/rút credits, game mapping |
/purchase | — | — | Navigate to="/" — mua qua modal |
/popups/send | SendShopPopupPage | protected | Popup in-app |
/broadcasts/send | SendBroadcastPage | protected | Broadcast |
/settings | SettingsPage | protected | 2FA, verify, low balance |
/payment/{success,cancelled,error} | AgentPayment*Page | protected | Redirect provider |
const { isAuthenticated, isLoading } = useAuth(); if (isLoading) return <PageLoadingIndicator variant="section" />; // chờ đọc localStorage if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
Vì là BrowserRouter (không hash), reload sâu (vd /shops/123) cần server rewrite về index.html. Repo đã có public/_redirects, vercel.json và public/web.config để lo việc này cho Netlify/Vercel/IIS.
Luồng đăng nhập & JWT
Đăng nhập 1 bước (email + password, không cần mã email như kiosk). Token và profile lưu localStorage; AuthProvider khởi tạo state từ đó khi mở app.
LoginPage gọi loginWithUsernameOrEmail() → POST /agent-auth/login. Có thể trả OTP challenge (verifyPortalLoginOtp).
toAgentProfile(res.data.agent) (sanitizeUser.ts) chỉ giữ id/email/name/status/costRate — bỏ passwordHash và field nhạy cảm trước khi lưu.
login(profile, accessToken) ghi agent_auth_token + agent_user_data vào localStorage, dispatch agent-auth-token-changed để popup gate refetch.
navigate(from) quay về trang người dùng định vào trước khi bị chặn (mặc định /).
(1) Không có refresh token thực thi. Backend trả cả refreshToken nhưng client chỉ lưu accessToken. Bất kỳ phản hồi 401 nào (interceptor) sẽ phát auth:unauthorized → AuthProvider gọi logout() ngay, đẩy về /login. Hết hạn token = đăng xuất, không tự gia hạn. (2) Không dùng cookie/SameSite — token nằm hoàn toàn trong localStorage.
services/api.ts — bộ não giao tiếp
Một AxiosInstance duy nhất, mỗi endpoint là một hàm export. Request interceptor tự gắn token; response interceptor bắt 401. Không có lớp service trung gian như kiosk — page/component gọi thẳng các hàm này.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'— đã bao gồm/apinên các path là/agent/...(không lặp lại/api) - Timeout: 15000ms; header mặc định
Content-Type: application/json - Token: request interceptor đọc
localStorage['agent_auth_token']→Authorization: Bearer <token> - 401: response interceptor
window.dispatchEvent(new Event('auth:unauthorized'))rồi reject - Envelope: mọi data trả về dạng
{ success, data?, message? }
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((res) => res, (error) => { if (error.response?.status === 401) window.dispatchEvent(new Event('auth:unauthorized')); return Promise.reject(error); });
Bảng endpoint đầy đủ (prefix base /api)
| Hàm | Method · Path | Vai trò |
|---|---|---|
loginWithUsernameOrEmail | POST /agent-auth/login | Đăng nhập (+ OTP 2FA) |
verifyPortalLoginOtp | POST /agent-auth/verify-otp | Hoàn tất 2FA |
requestForgotPasswordOtp | POST /agent-auth/forgot-password/* | Reset mật khẩu |
fetchDashboard | GET /agent/dashboard | credit_balance + online_balance_net |
convertBalanceToCredits | POST /agent/convert-balance-to-credits | USD → credit |
fetchAgentOnlineBalanceLedger | GET /agent/online-balance/ledger | Ledger USD |
createAgentOnlineBalanceWithdrawal | POST /agent/online-balance/withdrawals | Rút crypto (OTP + challenge token) |
createShopCashCredits | POST /agent/shops/:id/cash-credits | Bán credit cho shop (cash) |
withdrawCreditsFromShop | POST /agent/shops/:id/credits/withdraw | Rút credit từ shop |
getAgentCashTransactions | GET /agent/cash-transactions | Sổ tiền mặt |
purchaseCredits | POST /agent/credits/purchase | Mua credit online |
listZeroxStaticWallets | GET /agent/payments/zeroxprocessing/static-wallets | Ví static 0x |
fetchProviderFeeConfigs | GET /agent/provider-fees | Phí provider |
fetchAgentPlayerFraudIndicators | GET /agent/player-fraud-indicators | Gian lận |
createShop | POST /agent/shops | Tạo shop (cost_rate) |
suspendShop / unsuspendShop | PATCH /agent/shops/:id/(un)suspend | Quản trị shop |
updateShopCost | PATCH /agent/shops/:id/cost | Đổi cost_rate |
fetchAgentTransactions | GET /agent/transactions | Ledger credit + export |
fetchAgentBroadcasts / ack | GETPOST /agent/broadcasts[/:id/ack] | Chuông thông báo |
fetchAgentInAppPopupsUnseen | GET /agent/in-app-popups/unseen | Popup chặn màn hình |
hooks/useSupportedZeroxMethods.ts tự tạo axios riêng gọi GET /payment/supported-methods?provider=zeroxprocessing&operation=deposit (public, không Bearer) và cache 1 ngày trong localStorage (zerox_supported_methods:deposit). Dùng để dựng danh sách coin trong modal mua credit.
types/index.ts — shape dữ liệu domain
Type quan trọng nhất xoay quanh ví và cost. Lưu ý nhiều field có cả camelCase lẫn snake_case do backend trả không nhất quán — code phải phòng cả hai.
interface AgentProfile { id: string; email: string; name: string; status: string; costRate?: number; // rate mua vào của agent (0–1) } interface AgentDashboardData { credit_balance: number; active_shops_count: number; low_balance_warning_enabled?: boolean; /* ... cờ quyền popup ... */ } interface AgentWalletLedgerEntry { // 1 dòng /agent/transactions id: string; type: string; amount: number | null; direction: 'in' | 'out' | 'unknown'; counterparty: { ownerType: string; ownerId: string } | null; costRate?: number; costAmount?: number; // commission ghi ở từng dòng platformFeeRate?: number; platformFeeAmount?: number; createdAt?: string; created_at?: string; // ⚠ cả 2 kiểu } interface AgentShop { id: string; name: string; status: AgentShopStatus; costRate?: string | number; // ⚠ string HOẶC number minShopCostRateExclusive?: number; // cost shop phải > mức này }
costRate là số thập phân
0.05 = 5%. AgentShop.costRate có thể là string hoặc number → luôn Number(...) trước khi tính. UI hiển thị qua formatCostRate().
AgentCreditPurchaseData
Response của purchaseCredits: paymentUrl, paymentAddress, invoiceId, transactionId, amount (tiền phải trả provider), creditAmount (credit nhận), status.
snake vs camel
AgentPlayer dùng snake (shop_id, created_at), AgentWalletLedgerEntry dùng camel + fallback snake. Đọc field phải phòng cả hai biến thể.
AgentShopHierarchyStatsData
Có range, summary (tổng deposit/withdrawal/users) và mảng shops[] thống kê theo từng shop — cấp dữ liệu cho cây phân cấp Dashboard.
costRate & commission — mô hình lời của Agent
Đây là khái niệm quan trọng nhất của repo. Agent kiếm tiền nhờ chênh lệch cost rate: mua credit rẻ (theo agentCostRate), giao cho shop với giá đắt hơn (theo shopCostRate). Phần chênh là hoa hồng.
# Khi AGENT MUA credit từ platform: estimatedCharge (USD) = credits × agentCostRate # vd 100 credit × 0.30 = trả 30 USD cho provider # Khi AGENT GIAO credit cho shop: cashShopOwesAgent (USD) = credits × shopCostRate # vd 100 credit × 0.50 = shop nợ agent 50 USD # ⇒ LỢI NHUẬN agent / 100 credit: margin = credits × (shopCostRate − agentCostRate) # = 100 × (0.50 − 0.30) = 20 USD
Trong ShopsPage.onCreate, cost shop phải lớn hơn ngặt agent cost: if (rate <= minShopCostExclusive + 1e-6) reject. Giá trị mốc lấy từ fetchShops().agent_cost_rate (fallback user.costRate). Nếu không thoả, agent sẽ lỗ trên mỗi credit giao đi.
utils/costFormat.ts — chuyển đổi % ⇄ rate
// rate (0.05) → hiển thị "5%" (Intl.NumberFormat style:'percent') export function formatCostRate(raw: string|number|null|undefined): string // input "5" (người dùng gõ %) → rate 0.05, clamp [0,100] export function percentInputToRate(percentStr: string): number { const pct = parseFloat(percentStr); return Math.min(100, Math.max(0, pct)) / 100; }
API và type dùng rate thập phân (0.05). UI form dùng phần trăm (5). Luôn đi qua percentInputToRate / formatCostRate để chuyển; đừng gửi thẳng "5" lên cost_rate (sẽ thành 500%).
CreditPurchaseModal — mua credit từ platform
Modal toàn màn hình (components/credits/CreditPurchaseModal.tsx). Người dùng nhập số credit muốn nhận, chọn phương thức, hệ thống ước tính tiền phải trả rồi tạo order và nhúng trang thanh toán provider.
Chỉ số nguyên dương (replace(/\D/g,'')). estimatedCharge = credits × costRate hiển thị tức thời.
2 tab: Recommend (0x methods từ API, lọc chỉ BTC + USDT qua isAgentPurchaseVisibleTicker) và Other (BTCPay, LinkMePay PYUSD, Cash App…). Phân trang 8/trang, có ô search.
validateChargeUsd(): 0x cần $5–$10,000; Cash App chỉ nhận mệnh giá trong ECASH_APP_ALLOWED_AMOUNTS; còn lại tối thiểu $10.
purchaseCredits(credits, paymentMethod) → nhận paymentUrl/paymentAddress. Modal nhúng iframe trang checkout (sandbox) + nút "Open in new tab".
Số dư credit cộng bất đồng bộ ở backend qua webhook IPN, không phải ngay khi đóng modal. Provider redirect về /payment/success|cancelled|error.
Mapping phương thức UI → API
export const ECASH_APP_ALLOWED_AMOUNTS = [25,31,40,50,60,100,125,130,150,200,300,400,500]; export function mapUiPaymentIdToAgentPaymentMethod(id: string) { if (id.startsWith('zerox_')) return id; // zerox_usdt, zerox_btc... return { btcpay: 'btc_onchain', ltc_btcpay: 'ltc_chain', doge_btcpay: 'doge_chain', py_usd: 'pyusd', cashapp: 'ecash_app' }[id] ?? id; }
zeroxPaymentOptions.ts là danh mục ~20 coin tĩnh (tên, ticker, network, cờ comingSoon). Danh sách thật đến từ API /payment/supported-methods; bản tĩnh chỉ enrich tên/network/icon. Nhiều coin gắn comingSoon: true nên bị disable.
SellCreditsCashToShopTrigger — cash-credits flow
Thay cho phân phối credit "miễn phí" cũ, agent bán credit qua POST /agent/shops/:id/cash-credits. UI dùng SellCreditsCashModal từ @kioskgaming/ui.
// Payload: credits, payment_method?, received_at?, note? await createShopCashCredits(shopId, { credits: 100, payment_method: 'cash' }); // → ghi cash-transaction + trừ agent credit + cộng shop credit
WithdrawCreditsFromShopTrigger → withdrawCreditsFromShop hoàn credit về ví agent khi cần thu hồi.
Vòng đời & governance của shop
ShopsPage (danh sách + tạo) và ShopDetailPageRedesign (chi tiết) là nơi agent thực thi toàn quyền CRUD trên shop của mình, gồm đặt giá (cost), reset mật khẩu, bật game và quản trị trạng thái.
| Trạng thái | Hàm API | Agent có thể làm |
|---|---|---|
| active | suspendShop, terminateShop | Phân phối credit, đổi cost/password, suspend, terminate |
| suspended | unsuspendShop, terminateShop | Mở lại hoặc chấm dứt; bị chặn đăng nhập |
| terminated | — | Chỉ xem; không hồi phục, ví đóng, player gỡ gán |
ShopGovernanceModal — xác nhận hành động nguy hiểm
Dùng Modal từ @kioskgaming/ui với variant='delete' cho terminate (cảnh báo đỏ "irreversible") và variant='confirm' cho suspend (cảnh báo vàng). Callback onConfirmSuspend/Terminate trả Promise<boolean>; chỉ đóng modal khi thành công.
createShop gửi { name, email, phone?, password, cost_rate }. Form dùng react-hook-form + PhoneInput. Trước khi gửi, cost_rate được tính qua percentInputToRate và phải > agent cost (xem mục Kinh tế credit).
Lịch sử ví & agentWalletLedger.ts
AgentTransactionsPage dùng hook useAgentTransactions({page, limit}) (mặc định limit 25) gọi /agent/transactions. Vì shape ledger phức tạp, mọi logic hiển thị gom vào utils/agentWalletLedger.ts.
signedChange(tx)
Tính số dương/âm cho ví đang xem: ưu tiên direction ('in'/'out') + amount; fallback balanceAfter − balanceBefore. Cần thiết vì với credit ngoài (fromWallet=null) balance là 0→0.
walletTypeLabel(type)
Map deposit/withdrawal/transfer/bonus/adjustment/reservation… sang nhãn đẹp; type lạ thì replace(/_/g,' ').
referenceLabel / distributionTarget
Lấy referenceId/transactionId để hiển thị; nhận diện đối tác là shop khi counterparty.ownerType === 'shop' (giao dịch phân phối).
formatSignedAmount(n)
Thêm dấu +/- và format toLocaleString (2–8 chữ số thập phân).
fetchAgentTransactions nhận shop_id để chỉ trả các dòng có shop đó là đối tác (shop phải thuộc agent). Hook mặc định không truyền tham số này — UI muốn lọc phải tự thêm.
Players — chỉ đọc
Agent không tạo/sửa/xoá player. PlayersPage (/agent/players) liệt kê toàn bộ player thuộc mọi shop; AgentPlayerDetailPage + AgentPlayerDetailPanel hiển thị hồ sơ + số dư + lịch sử ví (/agent/players/:id/transactions).
AgentPlayer dùng snake_case (shop_id, shop_assigned_at, created_at). AgentPlayerDetail mở rộng thêm balance, last_login_at, shop_name. Tất cả là visibility-only — không có endpoint mutation player nào trong api.ts.
Hai hệ thống thông báo riêng biệt
Dễ nhầm: app có 2 cơ chế khác nhau với endpoint khác nhau. Một là chuông thông báo (không chặn), hai là popup chặn màn hình.
- Nguồn:
/agent/broadcasts, poll mỗi 2 phút - Badge đếm chưa đọc; click →
ackAgentBroadcast(id) - Không chặn thao tác; dropdown trong header
- Nguồn:
/agent/in-app-popups/unseen - Chặn toàn màn hình qua
createPortal, có hàng đợi (n of N) once→ mark seen;sticky_daily→ OK / "Don't show again"- Refetch khi sự kiện
agent-auth-token-changed
Markup link trong broadcast
utils/broadcastLinkMarkup.ts parse cú pháp <link>label|https://...</link> thành segment text/link an toàn (chỉ chấp nhận http/https qua sanitizeBroadcastUrl). BroadcastMessageBody.tsx render kết quả — dùng chung cho cả chuông và popup gate.
SendBroadcastPage → createAgentBroadcast (chuyển targetShopIds/targetUserIds sang snake_case target_shop_ids/target_user_ids). SendShopPopupPage → createAgentInAppPopup với display_mode mặc định 'once'.
Styling, config & build
Tailwind v3 có file config (khác kiosk v4), kết hợp design token HSL của package UI dùng chung. Config app gói gọn trong config/env.ts + constants/index.ts.
Tailwind v3 + token UI
tailwind.config.js quét ./src/** và ../packages/ui/dist/**; mở rộng màu từ biến HSL (--primary, --muted, --destructive…). Phong cách chủ đạo: cam/amber (orange-600) cho hành động agent.
Env (CRA prefix)
Mọi biến phải có tiền tố REACT_APP_: REACT_APP_API_BASE_URL, REACT_APP_TURNSTILE_ENABLED/SITE_KEY, REACT_APP_APP_NAME. Có sẵn .env.example với PORT=3003.
Build & deploy
craco start/build (PORT 3003). SPA fallback: _redirects, vercel.json, web.config.
Testing
Cấu hình test mặc định CRA (react-scripts test, jest + RTL qua react-app/jest) nhưng repo chưa có file test thực. TS bật strict, target es5.
Backend dùng bảng domains (type admin) làm allowlist CORS — phải thêm localhost:3003 trong Admin Domains hoặc đặt DISABLE_CORS=true khi dev. Ngoài ra @kioskgaming/ui & @kioskgaming/page-loading cài qua file:../packages/*; nếu thiếu ../packages thì npm install sẽ fail.
Cookbook cho dev mới
Các tác vụ phổ biến theo đúng quy ước của repo.
- Thêm hàm export trong
src/services/api.tsdùngclient.get/post/patch/put(...)(token tự gắn). - Khai báo type request/response trong
src/types/index.ts(nhớ envelope{ success, data?, message? }). - Gọi từ page/component với
useState/useEffect; báo lỗi quatoast(react-hot-toast). - Path bắt đầu bằng
/agent/...— không thêm/api(đã nằm trong baseURL).
- Tạo page trong
src/pages/, bọc nội dung trong<AppLayout><AgentPageLayout>. - Thêm
<Route>trongApp.tsx, bọc<ProtectedRoute>nếu cần đăng nhập. - Muốn xuất hiện ở sidebar/bottom-nav: thêm vào
NAV_ITEMStrongAppLayout.tsx(đặtprimary: trueđể hiện ở bottom-nav mobile).
- Hiển thị:
formatCostRate(shop.costRate)(luôn an toàn với string|number|null). - Nhận từ form %:
percentInputToRate(input)rồi gửi fieldcost_rate. - Khi tạo/đổi cost shop: kiểm tra
rate > agentCostRatetrước khi gọi API.
- Nếu là coin 0x: thêm vào
ZEROX_PAYMENT_OPTIONS(chỉ enrich; phải được API/payment/supported-methodshỗ trợ mới hiện). - Nếu là provider khác: thêm vào
otherPurchasePaymentOptions+ ánh xạ id trongmapUiPaymentIdToAgentPaymentMethod. - Bổ sung quy tắc validate (nếu cần) vào
validateChargeUsdtrongCreditPurchaseModal.
Bẫy thường gặp (đọc trước khi sửa)
| Bẫy | Chi tiết & cách xử lý |
|---|---|
| Lẫn rate vs phần trăm | costRate là thập phân (0.05). Form dùng %. Luôn qua percentInputToRate/formatCostRate. |
Quên /api đã nằm trong baseURL | Path trong api.ts chỉ là /agent/.... Đừng viết /api/agent/... (thành /api/api/...). |
| Không có refresh token | Mọi 401 → auth:unauthorized → logout ngay. Token hết hạn = đăng xuất. refreshToken trả về nhưng không dùng. |
ShopDetailPage.tsx là dead code | Route /shops/:id dùng ShopDetailPageRedesign (import alias thành ShopDetailPage). Sửa bản gốc sẽ không có tác dụng. |
DistributeCreditsTrigger đã bỏ | Phân phối credit giờ qua createShopCashCredits (bán tiền mặt), không còn /agent/credits/distribute trong frontend. |
PurchaseCheckoutPage đã bỏ | Mua credit chỉ qua CreditPurchaseModal; /purchase redirect về /. |
| Hai ví credit + USD | Dashboard có credit_balance và online_balance_net. Rút USD cần OTP + withdrawChallengeToken. |
| ECASH chặn theo charge USD | ECASH_APP_ALLOWED_AMOUNTS so với estimatedCharge (= credit × cost), không phải số credit. |
| costRate có thể là string | AgentShop.costRate kiểu string | number → luôn Number(...) trước khi nhân. |
| created_at vs createdAt | Ledger entry có cả hai; player dùng snake. Đọc field phải fallback cả hai biến thể. |
| 2 hệ thông báo dễ nhầm | Chuông (/agent/broadcasts, poll 2′) ≠ popup chặn (/agent/in-app-popups). Endpoint & component khác nhau. |
| Số dư cộng bất đồng bộ | Sau mua credit, số dư cập nhật qua webhook IPN ở backend — không phải khi đóng modal. Trang return chỉ là thông báo. |
| Cần package nội bộ | @kioskgaming/ui & @kioskgaming/page-loading qua file:../packages/*. Thiếu thư mục packages → install/build fail. |
| CORS theo bảng domains | Thêm localhost:3003 vào Admin → Domains (type admin) hoặc DISABLE_CORS=true khi dev local. |