Shop · Deep dive

Kỹ thuật chi tiết — Shop Portal

Đi sâu vào cơ chế thực thi của cổng quản lý cửa hàng: kiến trúc CRA + React Router, service layer axios, luồng auth JWT, state machine tra cứu player, hai luồng OTP, và cách ví credit/USD chảy trong chuỗi B2B Agent→Shop→Cashier→Player. Đọc sau báo cáo tổng quan 07-shop.html.

CRA + Craco 7 TypeScript 4.9 React Router 6.8 Tailwind 3.2 axios 1.3

Stack & tư duy tổng thể

Trước khi đọc code, hãy nắm 6 đặc điểm "khác biệt" nhất của app này — đặc biệt so với app Kiosk Frontend (Next.js) đã mô tả ở báo cáo 02. Hiểu chúng rồi thì đọc code rất nhanh.

🛍️

App B2B nhiều trang thật sự

Khác kiosk (state machine 1 màn hình), đây là portal CRA nhiều route dùng react-router-dom v6. Có sidebar, bottom nav mobile, 16+ route — điều hướng bằng URL thật.

🔌

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

Một instance axios singleton trong services/api.ts với interceptor tự gắn Bearer token và bắt 401. Toàn bộ ~35 hàm API export từ file này.

🧠

State = Context auth + useState cục bộ

Chỉ có 1 React Context (AuthProvider). Không Redux/Zustand/React Query. Mỗi trang tự fetch bằng useState + useCallback + 2 custom hook.

🔐

Có route guard thật

Mọi route (trừ /login) bọc <ProtectedRoute> — chuyển hướng về login nếu chưa auth. Đây là khác biệt lớn so với kiosk frontend (không có guard).

📡

Đồng bộ bằng custom DOM events

Không có store toàn cục, app dùng window.dispatchEvent: auth:unauthorized, shop-auth-token-changed, shop-credit-balance-refresh để các component rời nhau cùng cập nhật.

💵

Hai loại "tiền" cần phân biệt

Credits (ví shop, mua từ Agent theo cost_rate) và USD (số tiền thật phải trả qua payment provider). Player nhận credits từ ví shop.

Shop trong chuỗi B2B: Agent → Shop → Cashier → Player

Shop là tầng trung gian: nhận credits từ Agent (theo tỉ giá), rồi phân phối credits xuống Player tại địa điểm kinh doanh. Cashier là người vận hành tại quầy. Việc kiếm USD đến từ chênh lệch giá khi nạp cho player.

Super Agent Agent Shop (bạn) Cashier Player

Dòng credit vào

Shop mua credits qua POST /shop/credits/purchase. Giá USD = credits × cost_rate. cost_rate (0–1) do Agent đặt, lưu trong profile shop.

Dòng credit ra

Nạp credits cho player qua POST /shop/players/:id/credits (action: 'add'). Trừ ngay khỏi ví shop. Player chơi game bằng số credits này.

Ví & ledger

Ví shop hiển thị ở sidebar + dashboard. Mọi biến động ghi vào wallet_transactions (xem GET /shop/transactions), enrich qua presentation.

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

Người mới nên mở: src/App.tsx (routes) → services/api.ts (toàn bộ API) → hooks/useAuth.tsx (auth) → components/ShopPlayerLinkByPhonePanel.tsx (lõi nghiệp vụ player + OTP) → pages/ShopCreditPurchaseModal.tsx (luồng mua credits). ~5 file này là 70% hệ thống.

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

Cây thư mục phẳng theo phong cách CRA. Quy ước: page PascalCase trong pages/, component dùng chung trong components/, không có alias @/* — import bằng đường dẫn tương đối.

src/ ├── index.tsx # Entry: createRoot + StrictMode + registerServiceWorker (PWA) ├── App.tsx # ★ BrowserRouter + toàn bộ Routes + AuthProvider + Toaster ├── index.css # @tailwind + biến HSL :root + style cho PhoneInput ├── pages/ # 11 page component (1 route ↔ 1 file) │ ├── DashboardPage.tsx # 4 stat card + overview + giao dịch gần đây │ ├── PlayersPage.tsx # List + 2 modal (create / transfer) │ ├── PlayerDetailPage.tsx # /players/:playerId │ ├── ShopCreditPurchaseModal.tsx # Mua credits (dark UI, 0x + other) │ ├── TransactionsPage.tsx # Ledger ví shop (200 dòng) │ ├── KioskDomainPage.tsx # Subdomain create/edit │ ├── SettingsPage.tsx # Cảnh báo số dư thấp │ ├── SendBroadcastPage.tsx # Gửi bell notification │ ├── SendPlayerPopupPage.tsx # Gửi in-app popup │ ├── TransferToPlayerPage.tsx # Legacy (/transfer/legacy) │ └── ShopPaymentReturnPage.tsx # 3 trang return (success/cancel/error) ├── components/ │ ├── ShopPlayerLinkByPhonePanel.tsx # ★ Lõi lookup + OTP (dùng chung 2 modal) │ ├── ShopNotificationBell.tsx # Chuông broadcast (poll 2 phút) │ ├── ShopInAppPopupGate.tsx # Gate popup toàn cục (createPortal) │ ├── auth/ # LoginPage, ProtectedRoute │ ├── credits/ # Trigger/Modal mua credits + danh mục payment │ ├── layout/ AppLayout.tsx # Sidebar + header + bottom nav + ví │ └── dashboard/, home/, players/, ui/ ├── services/ api.ts # ★ 1 axios client + ~35 hàm API ├── hooks/ useAuth.tsx, useShopWalletTransactions.ts, useSupportedZeroxMethods.ts ├── types/ index.ts # Toàn bộ interface domain ├── constants/ index.ts # API_BASE_URL, STORAGE_KEYS, event names ├── config/ env.ts # Đọc process.env.REACT_APP_* └── utils/ phone.ts, shopWalletLedger.ts, sanitizeUser.ts …

Màn hình khởi động & mô hình render

Là CRA SPA thuần client (không SSR). index.tsx render <App/> trong StrictMode và đăng ký service worker (PWA).

1
index.tsx mount root

ReactDOM.createRoot(#root).render(<App/>) + registerServiceWorker(). CSS toàn cục từ index.css.

2
App.tsx dựng khung

<AuthProvider> bọc ngoài <BrowserRouter> + <Routes> + <Toaster/> (react-hot-toast, góc trên phải).

3
AuthProvider khôi phục phiên

useEffect đọc shop_auth_token + shop_user_data từ localStorage. Trong lúc đọc, isLoading=true → ProtectedRoute hiện spinner.

Render route hiện tại

Nếu đã auth → DashboardPage (bọc AppLayout). Chưa auth → redirect /login kèm state.from để quay lại sau khi đăng nhập.

💡
Vì sao bọc AuthProvider NGOÀI BrowserRouter?

Để context auth tồn tại độc lập với router, và để interceptor 401 (dispatch auth:unauthorized) có thể trigger logout() dù người dùng đang ở bất kỳ route nào.

Routing & ProtectedRoute

Tất cả route khai báo phẳng trong App.tsx. Không route group, không nested layout (layout nằm trong từng page qua <AppLayout>). Mọi route trừ /login đều bọc <ProtectedRoute>.

PathComponentGuardGhi chú
/loginLoginPagePublic; nếu đã auth → redirect /
/DashboardPageStat cards + overview
/playersPlayersPageList + modal create/transfer
/players/:playerIdPlayerDetailPageChi tiết player
/transactionsTransactionsPageLedger ví shop
/cashiersCashiersPageQuản lý cashier
/shiftsShiftsPageCa làm việc
/cash-settlement/depositsCounterDepositsLedgerPageNạp quầy
/online-transactionsOnlineBalanceTransactionsPageVí USD
/withdrawalsWithdrawalsApprovalPageDuyệt rút player
/purchaseredirect /Mua qua modal
/purchaseRedirect → /
/kiosk-domainKioskDomainPageSubdomain
/settingsSettingsPageCảnh báo số dư thấp
/popups/sendSendPlayerPopupPageGửi in-app popup
/broadcasts/sendSendBroadcastPageGửi bell
/transferRedirect → /players
/transfer/legacyTransferToPlayerPageTrang transfer legacy
/payment/success|cancelled|errorShopPayment*PageReturn từ payment provider
*Catch-all → /
src/components/auth/ProtectedRoute.tsxtsx
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();

if (isLoading) return <PageLoadingIndicator variant="section" />;  // chờ khôi phục phiên
if (!isAuthenticated)
  return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>;
⚠️
Guard là client-side, không thay backend

ProtectedRoute chỉ chặn UI. Mọi endpoint /shop/* vẫn phải tự xác thực JWT phía server. Đừng coi guard FE là lớp bảo mật thật.

Luồng đăng nhập & JWT

Đăng nhập 1 bước: email + mật khẩu (+ Turnstile tùy chọn). Token JWT lưu localStorage. Phiên shop tách biệt hoàn toàn với hệ Admin (key riêng, không chia sẻ cookie).

1
Submit form login

react-hook-form validate email + password (≥6 ký tự). Nếu TURNSTILE_ENABLED → bắt buộc captcha token.

2
Gọi API

loginWithEmailPassword()POST /shop-auth/login với { email, password, captchaToken? }.

3
Chuẩn hóa profile

toShopProfile(res.data.shop) map cả camelCase & snake_case (costRate/cost_rate, agentId/agent_id).

4
login() lưu state

Ghi shop_auth_token + shop_user_data vào localStorage, set context, dispatch shop-auth-token-changed.

5
Điều hướng

navigate(from) — quay về route đã chặn trước đó (lấy từ location.state.from), mặc định /.

🔑

Lưu token

localStorage: shop_auth_token (chỉ accessToken), shop_user_data (JSON profile). Interceptor gắn Authorization: Bearer ở mọi request.

Hết hạn token

Khi nhận 401, interceptor dispatch auth:unauthorizedAuthProvider gọi logout() → xóa token + về login. Không có cơ chế refresh.

🐛
refreshToken trả về nhưng không dùng

ShopLoginResponse.data có cả accessToken & refreshToken, nhưng client chỉ lưu accessToken. Không có flow refresh chủ động hay phản ứng — accessToken hết hạn là đăng xuất luôn. Nếu cần phiên dài, phải tự bổ sung logic refresh.

services/api.ts — bộ não giao tiếp

Một file duy nhất export ~35 hàm async, tất cả dùng chung một axios client. Component không tự gọi axios — chúng import hàm cụ thể.

PATTERNSingle axios client + interceptors + envelope
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api' — lưu ý prefix /api đã nằm trong base, endpoint chỉ thêm /shop/...
  • Timeout: 15s; header mặc định Content-Type: application/json
  • Request interceptor: đọc shop_auth_token → gắn Authorization: Bearer
  • Response interceptor: nếu 401window.dispatchEvent('auth:unauthorized') rồi reject
  • Envelope chuẩn: { success, message?, data?, code? } — nhiều hàm trả thêm pagination
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((res) => res, (error) => {
  if (error.response?.status === 401)
    window.dispatchEvent(new Event('auth:unauthorized'));
  return Promise.reject(error);
});

Nhóm endpoint chính (prefix base đã có /api)

NhómHàm tiêu biểuEndpoint
AuthloginWithEmailPasswordPOST /shop-auth/login
DashboardfetchDashboardGET /shop/dashboard
PlayersfetchShopPlayers, fetchShopPlayer, lookupPlayerByPhone/shop/players, /shop/players/:id, /shop/players/lookup
OTP đăng kýregisterPlayerInitiate, ...Resend, verifyPlayerRegistration/shop/players/register-initiate|resend|verify
OTP transferinitiatePlayerTransfer, verifyPlayerTransfer/shop/players/transfer/initiate|verify|resend
Credits playerpostShopPlayerCreditsPOST /shop/players/:id/credits
Mua creditspurchaseShopCreditsPOST /shop/credits/purchase
LedgerfetchShopWalletTransactionsGET /shop/transactions
Kiosk domainfetch/create/patchShopKioskDomain/shop/kiosk-domain (GET/POST/PATCH)
Thông báofetchShopBroadcasts, createShopInAppPopup/shop/broadcasts, /shop/in-app-popups
Payment 0xuseSupportedZeroxMethodsGET /payment/supported-methods

Quản lý state & đồng bộ qua custom events

Vì không có store toàn cục, app dùng DOM CustomEvent làm "event bus" để các component không cha-con cùng cập nhật (đặc biệt là số dư ví).

Có gì
  • 1 Context: AuthProvider (user, token, isAuthenticated, isLoading)
  • 2 custom hook: useShopWalletTransactions, useSupportedZeroxMethods (có cache localStorage TTL 1 ngày)
  • 3 sự kiện DOM: xem bảng bên dưới
  • Toast: react-hot-toast (Toaster mount ở App.tsx)
Không có
  • Redux / Zustand / Jotai
  • React Query / SWR
  • Global wallet/player store (mỗi trang tự fetch)
  • Alias path @/* (import tương đối)
Sự kiệnAi dispatchAi lắng nghe / tác dụng
auth:unauthorizedresponse interceptor (401)AuthProvider → logout()
shop-auth-token-changedlogin() / logout()ShopInAppPopupGate refetch popup
shop-credit-balance-refreshdispatchShopCreditBalanceRefresh() sau khi nạp/return paymentAppLayout refetch số dư sidebar
🔄
Hai cơ chế refresh số dư khác

Ngoài event bus, AppLayout & ShopCreditPurchaseModal còn lắng nghe document.visibilitychange: nếu tab ẩn > 2 giây rồi quay lại (giả định người dùng vừa thanh toán ở tab khác) thì tự gọi fetchDashboard() để cập nhật số dư.

Tra cứu player theo số điện thoại — 4 trạng thái

Lõi nghiệp vụ nằm ở ShopPlayerLinkByPhonePanel.tsx — dùng chung cho cả modal "Create player" (flow="create") và "Transfer from another shop" (flow="transfer"). Người dùng nhập phone (qua react-phone-number-input), bấm lookup, server trả về một trong 4 status quyết định hành động tiếp theo.

nhập phone lookupPlayerByPhone() not_found | unassigned | in_shop | other_shop
StatusÝ nghĩaHành động UI
not_foundChưa có tài khoản với số nàyNút "Send OTP — create & link" → luồng đăng ký
unassignedCó tài khoản nhưng chưa thuộc shop nàoNút "Send OTP — link to this shop" → luồng đăng ký
in_shopĐã thuộc shop nàyKhông cần OTP; onInShop(phone) → focus search trong bảng
other_shopĐang thuộc shop khácChuyển sang luồng transfer (player phải duyệt OTP)
🔀
register-initiate cũng có thể trả "transfer_required"

Ngay cả khi đang ở flow="create", gọi registerPlayerInitiate có thể nhận về status: 'transfer_required' (kèm player_id) — hoặc qua body 2xx hoặc qua lỗi 4xx. Code xử lý cả hai: set lookupStatus='other_shop' và gợi ý người dùng đổi sang tab Transfer. Đây là điểm dễ bỏ sót khi đọc nhanh.

Hai luồng OTP: registration vs transfer

OTP đảm bảo sự đồng ý của player trước khi thay đổi shop assignment. Cùng một panel quản 2 luồng bằng biến otpFlowMode ('registration' | 'transfer') với 2 id khác nhau: registrationId hoặc transferId.

otpFlowModeKhi nàoAPI khởi tạoAPI verify
registrationnot_found / unassignedregisterPlayerInitiate({phone,email?,verification_method})verifyPlayerRegistration(id, otp)
transferother_shopinitiatePlayerTransfer(playerId)verifyPlayerTransfer(id, otp)
🔢

Đặc tả ô nhập OTP

6 số, lọc ký tự: value.replace(/\D/g,'').slice(0,6). inputMode="numeric", autocomplete="one-time-code" để mobile autofill.

⏱️

Resend & cooldown

Cooldown qua nextResendAt (đếm ngược mỗi giây bằng setInterval). Tối đa 5 lần resend; vượt → bắt restart. Lần đầu set +60s client-side.

Kênh OTP & verify (chỉ flow create)

Khi tạo player có thể chọn kênh sms hoặc email (nếu email → bắt buộc nhập email). Sau verify thành công ở flow registration, nếu response trả về data.player thì gọi onLinkedPlayer() để điều hướng sang trang chi tiết. Với flow transfer, panel còn lookup lại phone để lấy playerId mới rồi mới điều hướng.

🔒
Nạp credits cho player KHÔNG cần OTP

OTP chỉ áp dụng cho thay đổi shop assignment (create/link/transfer). Khi player đã in_shop, việc nạp credits (postShopPlayerCredits action add) chỉ qua confirmation modal — không OTP.

Ví credit, cost rate & ledger

Ví shop tính bằng credits. Số dư hiện ở sidebar (AppLayout) và 4 stat card của Dashboard. Mọi biến động đọc từ GET /shop/transactions và được "trình bày" qua helper trong utils/shopWalletLedger.ts.

cost_rate

Số 0–1 do Agent đặt. fetchDashboard().cost_rate hoặc fallback user.costRate. Hiển thị % qua (rate*100). USD phải trả = credits × rate.

Cảnh báo số dư

Sidebar dùng ngưỡng hardcode 100 credits (LOW_SHOP_CREDIT_BALANCE_THRESHOLD). Còn Settings cấu hình ngưỡng USD riêng (tối thiểu $50). Hai con số khác nhau!

Ledger presentation

getPresentation(row) merge field presentation từ server với counterparty parse từ metadata; tự suy direction (in/out) từ balanceBefore/After.

utils/shopWalletLedger.ts — normalizeMeta (rút gọn)ts
// Khớp backend normalizeLedgerMetadata: metadata có thể là JSON string, snake_case
export function normalizeMeta(raw: unknown): Record<string, unknown> {
  let m: unknown = raw;
  if (typeof m === 'string') { try { m = JSON.parse(m); } catch { return {}; } }
  const o = { ...(m as Record<string, unknown>) };
  if (o.player_phone != null) o.playerPhone = o.player_phone; // chuẩn hóa key
  return o;
}
🧩
Type ledger cố tình "rộng" để chịu được cả 2 casing

ShopWalletLedgerRow khai báo song song balanceBefore/balance_before, costRate/cost_rate… vì API có chỗ trả camelCase, chỗ snake_case (và có chỗ đặt trong metadata). Khi đọc giá trị, luôn dùng helper (toNum, getPresentation) thay vì truy cập trực tiếp.

ShopCreditPurchaseModal & payment provider

Trang dark UI cho shop mua thêm credits từ platform. Nhập số credits → tính USD ước tính → chọn phương thức → tạo order → nhận paymentUrl nhúng iframe.

1
Nhập credits (số nguyên)

Input lọc replace(/\D/g,''). estimatedCharge = creditNum × safeRate (rate kẹp về [0,1]).

2
Chọn tab phương thức

Recommend = các method 0x (useSupportedZeroxMethods('deposit'), có cache 1 ngày). Other = btcpay/linkmepay (BTC, LTC, DOGE, PYUSD, Cash App).

3
Validate theo method

zerox_*: $5–$10,000. cashapp: chỉ các mức trong ECASH_APP_ALLOWED_AMOUNTS. Khác: tối thiểu $10.

4
Tạo order

purchaseShopCredits(creditNum, mappedMethod)POST /shop/credits/purchase. mapUiPaymentIdToAgentPaymentMethod đổi id UI sang chuỗi backend (vd btcpay→btc_onchain).

5
Thanh toán

Hiện result.paymentUrl trong iframe (sandbox) + nút "Open in new tab". Cộng credits do webhook backend xử lý sau khi provider xác nhận.

🪙
Lọc ticker hiển thị

filterShopVisiblePurchaseOptions chỉ cho hiện BTC và bất kỳ biến thể USDT (regex /^USDT($|[\s(])/, khớp cả "USDT (TRC20)"). Các method khác bị ẩn dù API trả về.

🌐
SHOP_FRONTEND_URL phải đúng để redirect

Provider redirect về /payment/success|cancelled|error. Backend cần đặt SHOP_FRONTEND_URL trỏ đúng origin app này (dev: http://localhost:3004), nếu không người dùng sẽ lạc trang sau thanh toán.

Cấu hình subdomain cho địa điểm

Mỗi shop có 1 kiosk URL dạng label.<base>. KioskDomainPage có 3 trạng thái: chưa có (tạo), đã có (read-only + Edit), đang sửa.

KioskDomainPage.tsx — subdomainLabelFromFqdnts
// Tách label khỏi FQDN: "mystore.kioskgaming.com" + base "kioskgaming.com" → "mystore"
function subdomainLabelFromFqdn(fqdn: string, base: string): string {
  const suffix = `.${base}`;
  if (base && fqdn.endsWith(suffix)) return fqdn.slice(0, -suffix.length);
  return fqdn.split('.')[0] || '';
}
⚠️
Thiếu SHOP_KIOSK_BASE_DOMAIN → chặn UI

fetchShopKioskDomain() trả kiosk_base_domain. Nếu rỗng (server chưa config env SHOP_KIOSK_BASE_DOMAIN), trang hiện cảnh báo amber và không cho tạo domain. Preview URL render live khi gõ.

Broadcast bell & in-app popup gate

Shop vừa nhận thông báo từ Admin/Agent (chuông + popup), vừa gửi thông báo xuống player (broadcast + popup).

🔔

ShopNotificationBell (nhận)

Poll GET /shop/broadcasts mỗi 2 phút. Badge đếm chưa đọc; click 1 item → ackShopBroadcast(id). Đóng dropdown khi click ngoài.

🪟

ShopInAppPopupGate (nhận)

createPortal ra document.body. Fetch /shop/in-app-popups/unseen, queue popup. Mode once → "OK" gọi mark-seen; sticky_daily → có thêm "Don't show again".

📢

SendBroadcastPage (gửi)

Gửi bell tới player (multi-select active). Title ≤500, body ≤1000 ký tự. Guard can_create_player_popup từ dashboard.

💬

SendPlayerPopupPage (gửi)

Gửi in-app popup, displayMode: once | sticky_daily, tùy chọn expiresAt. Cùng guard can_create_player_popup.

🔗
Body hỗ trợ link

Cả bell lẫn popup render body qua BroadcastMessageBody (parse markup link từ utils/broadcastLinkMarkup.ts) — link mở được, có stopPropagation để không trigger "đánh dấu đã đọc" khi click link.

Tailwind 3 + workspace packages

Khác kiosk frontend (Tailwind v4 không config), app này dùng Tailwind 3.2 có file config với token màu HSL khai trong index.css. Tông chủ đạo emerald (primary 160 84% 39%).

src/index.css + tailwind.config.jscss
@tailwind base; @tailwind components; @tailwind utilities;

@layer base { :root {
  --primary: 160 84% 39%;   /* emerald — dùng qua hsl(var(--primary)) */
  --border: 160 10% 90%; --radius: 0.5rem;
} }

// tailwind.config.js — content quét cả package ui đã build
content: ['./src/**/*.{js,jsx,ts,tsx}', '../packages/ui/dist/**/*.js']

Internal packages

@kioskgaming/ui & @kioskgaming/page-loading (workspace file:../packages/*). Dùng PageLoadingIndicator/PageLoadingSpinner khắp nơi.

Icon & form

Icon: lucide-react. Form login: react-hook-form. Phone: react-phone-number-input (style override trong index.css class .transfer-phone-field).

Toast & captcha

Toast: react-hot-toast. Captcha: @marsidev/react-turnstile (bật theo env).

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

Đọc tập trung qua src/config/env.ts. Biến phải có tiền tố REACT_APP_ (quy ước CRA). Có sẵn .env.example.

BiếnMặc địnhÝ nghĩa
REACT_APP_API_BASE_URLhttp://localhost:3001/apiBase URL (đã gồm /api)
REACT_APP_APP_NAMEKiosk Gaming — ShopTên app
REACT_APP_TURNSTILE_ENABLEDfalse'true' mới bật captcha login
REACT_APP_TURNSTILE_SITE_KEYSite key Cloudflare Turnstile
PORT3004Cổng dev server (CRA)
📄
Khác kiosk frontend về base URL

Ở đây /api đã nằm trong API_BASE_URL nên endpoint chỉ ghi /shop/.... Đừng thêm /api thủ công lần nữa khi viết hàm mới.

Kiểm thử & build

Hạ tầng test là mặc định của CRA (react-scripts test = Jest + RTL) nhưng chưa có file test nào trong src/. Build dùng react-scripts build (CRA typecheck — khác kiosk frontend bỏ qua lỗi TS).

Scripts (package.json)bash
npm start    # react-scripts start → http://localhost:3004 (PORT)
npm run build  # react-scripts build (có typecheck)
npm test     # react-scripts test — hiện chưa có test case
npm run eject  # ⚠️ không nên dùng
🧪
Chưa có test thật

Không có thư mục tests/ hay file *.test.tsx. Khi thêm tính năng mới nên cân nhắc viết test cho helper thuần (vd shopWalletLedger, phone) vì chúng dễ unit test và đang gánh nhiều logic chuẩn hóa dữ liệu.

Cookbook cho dev mới

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

HOW-TOGọi một API backend mới
  1. Thêm hàm async trong src/services/api.ts dùng client.get/post/patch (đường dẫn bắt đầu /shop/..., KHÔNG thêm /api).
  2. Khai interface response trong src/types/index.ts (chịu cả camelCase/snake_case nếu cần).
  3. Trong page/component, tự quản loading/error bằng useState + useCallback; báo lỗi qua toast.
HOW-TOThêm một route mới
  1. Tạo src/pages/MyPage.tsx, bọc nội dung trong <AppLayout>.
  2. Khai <Route> trong App.tsx, bọc <ProtectedRoute> nếu cần auth.
  3. Nếu muốn vào sidebar, thêm mục vào NAV_ITEMS trong AppLayout.tsx (đặt primary: true để hiện cả ở bottom nav mobile).
HOW-TOBuộc số dư ví refresh sau thao tác tiền
  1. Sau khi nạp/transfer thành công, gọi dispatchShopCreditBalanceRefresh() (từ constants/).
  2. AppLayout đang lắng nghe sự kiện này → tự gọi lại fetchDashboard() cho card số dư sidebar.
  3. Trong page hiện tại, gọi thêm hàm reload cục bộ của bạn (silent mode nếu không muốn spinner).
HOW-TOThêm bước vào panel link player
  1. Mọi logic lookup/OTP nằm trong ShopPlayerLinkByPhonePanel.tsx — sửa ở đây, đừng nhân bản.
  2. Nhớ xử lý cả 4 lookupStatus và cả 2 otpFlowMode.
  3. Modal ở PlayersPage dùng key tăng dần để remount panel (clear state) — giữ pattern này khi mở lại modal.

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

BẫyChi tiết & cách xử lý
Double /apiAPI_BASE_URL đã có /api. Hàm mới chỉ ghi /shop/..., đừng thêm /api lần nữa.
refreshToken bị bỏClient chỉ lưu accessToken. 401 = đăng xuất luôn, không refresh. Cần phiên dài phải tự thêm logic.
Hai ngưỡng số dư khác nhauSidebar hardcode 100 credits; Settings là ngưỡng USD (min $50). Đừng nhầm hai con số.
register-initiate trả transfer_requiredỞ flow create vẫn có thể gặp other_shop — xử lý cả body 2xx lẫn lỗi 4xx (đã có sẵn trong panel).
Ledger 2 casingField có cả camelCase & snake_case, đôi khi nằm trong metadata. Luôn dùng helper getPresentation/normalizeMeta.
Chuỗi lẫn tiếng ViệtShopCreditPurchaseModal có 1 chuỗi "Đang tải phương thức thanh toán…" giữa UI tiếng Anh. Lưu ý nhất quán ngôn ngữ khi sửa.
Lọc payment tickerChỉ BTC & USDT* hiện ra (filterShopVisiblePurchaseOptions). Method khác bị ẩn dù API trả về.
SHOP_FRONTEND_URL / SHOP_KIOSK_BASE_DOMAINHai env server-side: thiếu cái đầu → redirect payment sai; thiếu cái sau → không tạo được kiosk domain.
Validate phone chỉ "đủ dùng"isPlausibleInternationalPhone chỉ check + và 7–15 chữ số. Server mới là nguồn xác thực thật.
Chưa có testnpm test chạy được nhưng không có case nào. Helper thuần là nơi nên bắt đầu viết test.