Agent · Deep dive

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ế creditcostRate 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.

React 18.2 CRA + Craco 7 TypeScript 4.9 Tailwind v3 React Router v6 Axios

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 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.tstypes/index.ts.

kioskgaming_agent/ ├── craco.config.js # webpack alias @kioskgaming/ui ├── ../packages/ui · ../packages/page-loading # workspace packages ├── kioskgaming_backend/ # clone tham khảo — KHÔNG dùng runtime └── src/ ├── App.tsx # Router + ProtectedRoute ├── pages/ │ ├── DashboardPage.tsx │ ├── ShopsPage.tsx · ShopDetailPageRedesign.tsx # ★ route /shops/:id │ ├── AgentTransactionsPage.tsx · OnlineBalanceTransactionsPage.tsx │ ├── cashTransactions/CashTransactionsLedgerPage.tsx │ ├── ProviderFeesPage.tsx · FraudIndicatorsPage.tsx │ └── SendBroadcastPage.tsx · SettingsPage.tsx ├── components/credits/ # ★ nghiệp vụ tiền │ ├── CreditPurchaseModal.tsx │ ├── SellCreditsCashToShopTrigger.tsx # POST cash-credits │ └── WithdrawCreditsFromShopTrigger.tsx ├── services/ api.ts # ~50 hàm → /agent/* ├── types/ index.ts └── hooks/ useAuth.tsx · useAgentOnlineBalanceLedger.ts
💡
Đọc theo thứ tự nào?

Người mới nên mở: App.tsxservices/api.tstypes/index.tsCreditPurchaseModal.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 */purchase đều Navigate về /.

URLComponentBảo vệGhi chú
/loginLoginPagepublicusername/email + password (+Turnstile, OTP 2FA)
/forgot-password/*3 trang resetpublicOTP email/phone → đặt mật khẩu mới
/DashboardPageprotectedCredit + USD balance sidebar
/transactionsAgentTransactionsPageprotectedLedger credit + export CSV
/online-transactionsOnlineBalanceTransactionsPageprotectedLedger USD + rút crypto
/cash-transactionsCashTransactionsLedgerPageprotectedGiao dịch tiền mặt shop
/provider-feesProviderFeesPageprotectedXem phí provider
/fraud-indicatorsFraudIndicatorsPageprotectedGian lận player
/shops/:shopIdShopDetailPageRedesignprotectedBán/rút credits, game mapping
/purchaseNavigate to="/" — mua qua modal
/popups/sendSendShopPopupPageprotectedPopup in-app
/broadcasts/sendSendBroadcastPageprotectedBroadcast
/settingsSettingsPageprotected2FA, verify, low balance
/payment/{success,cancelled,error}AgentPayment*PageprotectedRedirect provider
src/components/auth/ProtectedRoute.tsxtsx
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}</>;
🧭
SPA cần fallback rewrite

Vì là BrowserRouter (không hash), reload sâu (vd /shops/123) cần server rewrite về index.html. Repo đã có public/_redirects, vercel.jsonpublic/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.

1
Submit form

LoginPage gọi loginWithUsernameOrEmail()POST /agent-auth/login. Có thể trả OTP challenge (verifyPortalLoginOtp).

2
Làm sạch profile

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.

3
Lưu & phát sự kiện

login(profile, accessToken) ghi agent_auth_token + agent_user_data vào localStorage, dispatch agent-auth-token-changed để popup gate refetch.

Vào portal

navigate(from) quay về trang người dùng định vào trước khi bị chặn (mặc định /).

🐛
2 điểm cần biết về auth

(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:unauthorizedAuthProvider 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.

PATTERNAxios singleton + interceptor
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api'đã bao gồm /api nê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? }
src/services/api.ts (rút gọn)ts
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àmMethod · PathVai trò
loginWithUsernameOrEmailPOST /agent-auth/loginĐăng nhập (+ OTP 2FA)
verifyPortalLoginOtpPOST /agent-auth/verify-otpHoàn tất 2FA
requestForgotPasswordOtpPOST /agent-auth/forgot-password/*Reset mật khẩu
fetchDashboardGET /agent/dashboardcredit_balance + online_balance_net
convertBalanceToCreditsPOST /agent/convert-balance-to-creditsUSD → credit
fetchAgentOnlineBalanceLedgerGET /agent/online-balance/ledgerLedger USD
createAgentOnlineBalanceWithdrawalPOST /agent/online-balance/withdrawalsRút crypto (OTP + challenge token)
createShopCashCreditsPOST /agent/shops/:id/cash-creditsBán credit cho shop (cash)
withdrawCreditsFromShopPOST /agent/shops/:id/credits/withdrawRút credit từ shop
getAgentCashTransactionsGET /agent/cash-transactionsSổ tiền mặt
purchaseCreditsPOST /agent/credits/purchaseMua credit online
listZeroxStaticWalletsGET /agent/payments/zeroxprocessing/static-walletsVí static 0x
fetchProviderFeeConfigsGET /agent/provider-feesPhí provider
fetchAgentPlayerFraudIndicatorsGET /agent/player-fraud-indicatorsGian lận
createShopPOST /agent/shopsTạo shop (cost_rate)
suspendShop / unsuspendShopPATCH /agent/shops/:id/(un)suspendQuản trị shop
updateShopCostPATCH /agent/shops/:id/costĐổi cost_rate
fetchAgentTransactionsGET /agent/transactionsLedger credit + export
fetchAgentBroadcasts / ackGETPOST /agent/broadcasts[/:id/ack]Chuông thông báo
fetchAgentInAppPopupsUnseenGET /agent/in-app-popups/unseenPopup chặn màn hình
📍
Một endpoint public ngoài api.ts

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.

src/types/index.ts (trích)ts
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

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.

Platform — mua @ agentCostRate → Ví Agent (credit) — giao @ shopCostRate → Ví Shop
Công thức (từ CreditPurchaseModal & DistributeCreditsTrigger)txt
# 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
💡
Ràng buộc bất biến: shop cost > agent cost

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

src/utils/costFormat.tsts
// 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;
}
⚠️
Bẫy đơn vị

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.

1
Nhập số credit

Chỉ số nguyên dương (replace(/\D/g,'')). estimatedCharge = credits × costRate hiển thị tức thời.

2
Chọn phương thức

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.

3
Validate theo phương thức

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.

4
Tạo order & thanh toán

purchaseCredits(credits, paymentMethod) → nhận paymentUrl/paymentAddress. Modal nhúng iframe trang checkout (sandbox) + nút "Open in new tab".

Provider xác nhận → webhook

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

src/components/credits/creditPurchasePaymentMethods.tsxts
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;
}
🔮
ZEROX_PAYMENT_OPTIONS chỉ để "tô màu"

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.

createShopCashCredits (api.ts)ts
// 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
📥
Rút credits

WithdrawCreditsFromShopTriggerwithdrawCreditsFromShop 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.

active ⇄ suspend/unsuspend suspended — terminate → terminated
Trạng tháiHàm APIAgent có thể làm
activesuspendShop, terminateShopPhân phối credit, đổi cost/password, suspend, terminate
suspendedunsuspendShop, terminateShopMở lại hoặc chấm dứt; bị chặn đăng nhập
terminatedChỉ 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.

⚖️
Tạo shop = phải set cost hợp lệ

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).

📌
Lọc theo shop

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).

🔒
Phạm vi quyền

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.

🔔 AgentNotificationBell
  • 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
🚪 AgentInAppPopupGate
  • 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.

✍️
Soạn & gửi

SendBroadcastPagecreateAgentBroadcast (chuyển targetShopIds/targetUserIds sang snake_case target_shop_ids/target_user_ids). SendShopPopupPagecreateAgentInAppPopup với display_mode mặc định 'once'.

Styling, config & build

Tailwind v3 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/**../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.

🔌
CORS & package nội bộ

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.

HOW-TOThêm một endpoint backend mới
  1. Thêm hàm export trong src/services/api.ts dùng client.get/post/patch/put(...) (token tự gắn).
  2. Khai báo type request/response trong src/types/index.ts (nhớ envelope { success, data?, message? }).
  3. Gọi từ page/component với useState/useEffect; báo lỗi qua toast (react-hot-toast).
  4. Path bắt đầu bằng /agent/...không thêm /api (đã nằm trong baseURL).
HOW-TOThêm một route mới
  1. Tạo page trong src/pages/, bọc nội dung trong <AppLayout><AgentPageLayout>.
  2. Thêm <Route> trong App.tsx, bọc <ProtectedRoute> nếu cần đăng nhập.
  3. Muốn xuất hiện ở sidebar/bottom-nav: thêm vào NAV_ITEMS trong AppLayout.tsx (đặt primary: true để hiện ở bottom-nav mobile).
HOW-TOLàm việc với cost rate
  1. Hiển thị: formatCostRate(shop.costRate) (luôn an toàn với string|number|null).
  2. Nhận từ form %: percentInputToRate(input) rồi gửi field cost_rate.
  3. Khi tạo/đổi cost shop: kiểm tra rate > agentCostRate trước khi gọi API.
HOW-TOThêm một phương thức thanh toán mua credit
  1. Nếu là coin 0x: thêm vào ZEROX_PAYMENT_OPTIONS (chỉ enrich; phải được API /payment/supported-methods hỗ trợ mới hiện).
  2. Nếu là provider khác: thêm vào otherPurchasePaymentOptions + ánh xạ id trong mapUiPaymentIdToAgentPaymentMethod.
  3. Bổ sung quy tắc validate (nếu cần) vào validateChargeUsd trong CreditPurchaseModal.

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

BẫyChi tiết & cách xử lý
Lẫn rate vs phần trămcostRate là thập phân (0.05). Form dùng %. Luôn qua percentInputToRate/formatCostRate.
Quên /api đã nằm trong baseURLPath trong api.ts chỉ là /agent/.... Đừng viết /api/agent/... (thành /api/api/...).
Không có refresh tokenMọ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 codeRoute /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 + USDDashboard có credit_balanceonline_balance_net. Rút USD cần OTP + withdrawChallengeToken.
ECASH chặn theo charge USDECASH_APP_ALLOWED_AMOUNTS so với estimatedCharge (= credit × cost), không phải số credit.
costRate có thể là stringAgentShop.costRate kiểu string | number → luôn Number(...) trước khi nhân.
created_at vs createdAtLedger 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ầmChuô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 domainsThêm localhost:3003 vào Admin → Domains (type admin) hoặc DISABLE_CORS=true khi dev local.