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.
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.
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.
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.
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).
ReactDOM.createRoot(#root).render(<App/>) + registerServiceWorker(). CSS toàn cục từ index.css.
<AuthProvider> bọc ngoài <BrowserRouter> + <Routes> + <Toaster/> (react-hot-toast, góc trên phải).
useEffect đọc shop_auth_token + shop_user_data từ localStorage. Trong lúc đọc, isLoading=true → ProtectedRoute hiện spinner.
Nếu đã auth → DashboardPage (bọc AppLayout). Chưa auth → redirect /login kèm state.from để quay lại sau khi đăng nhập.
Để 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>.
| Path | Component | Guard | Ghi chú |
|---|---|---|---|
/login | LoginPage | — | Public; nếu đã auth → redirect / |
/ | DashboardPage | ✓ | Stat cards + overview |
/players | PlayersPage | ✓ | List + modal create/transfer |
/players/:playerId | PlayerDetailPage | ✓ | Chi tiết player |
/transactions | TransactionsPage | ✓ | Ledger ví shop |
/cashiers | CashiersPage | ✓ | Quản lý cashier |
/shifts | ShiftsPage | ✓ | Ca làm việc |
/cash-settlement/deposits | CounterDepositsLedgerPage | ✓ | Nạp quầy |
/online-transactions | OnlineBalanceTransactionsPage | ✓ | Ví USD |
/withdrawals | WithdrawalsApprovalPage | ✓ | Duyệt rút player |
/purchase | redirect / | — | Mua qua modal |
/purchase | — | — | Redirect → / |
/kiosk-domain | KioskDomainPage | ✓ | Subdomain |
/settings | SettingsPage | ✓ | Cảnh báo số dư thấp |
/popups/send | SendPlayerPopupPage | ✓ | Gửi in-app popup |
/broadcasts/send | SendBroadcastPage | ✓ | Gửi bell |
/transfer | — | ✓ | Redirect → /players |
/transfer/legacy | TransferToPlayerPage | ✓ | Trang transfer legacy |
/payment/success|cancelled|error | ShopPayment*Page | ✓ | Return từ payment provider |
* | — | — | Catch-all → / |
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}</>;
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).
react-hook-form validate email + password (≥6 ký tự). Nếu TURNSTILE_ENABLED → bắt buộc captcha token.
loginWithEmailPassword() → POST /shop-auth/login với { email, password, captchaToken? }.
toShopProfile(res.data.shop) map cả camelCase & snake_case (costRate/cost_rate, agentId/agent_id).
Ghi shop_auth_token + shop_user_data vào localStorage, set context, dispatch shop-auth-token-changed.
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:unauthorized → AuthProvider gọi logout() → xóa token + về login. Không có cơ chế refresh.
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ể.
- 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ắnAuthorization: Bearer - Response interceptor: nếu
401→window.dispatchEvent('auth:unauthorized')rồi reject - Envelope chuẩn:
{ success, message?, data?, code? }— nhiều hàm trả thêmpagination
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óm | Hàm tiêu biểu | Endpoint |
|---|---|---|
| Auth | loginWithEmailPassword | POST /shop-auth/login |
| Dashboard | fetchDashboard | GET /shop/dashboard |
| Players | fetchShopPlayers, fetchShopPlayer, lookupPlayerByPhone | /shop/players, /shop/players/:id, /shop/players/lookup |
| OTP đăng ký | registerPlayerInitiate, ...Resend, verifyPlayerRegistration | /shop/players/register-initiate|resend|verify |
| OTP transfer | initiatePlayerTransfer, verifyPlayerTransfer | /shop/players/transfer/initiate|verify|resend |
| Credits player | postShopPlayerCredits | POST /shop/players/:id/credits |
| Mua credits | purchaseShopCredits | POST /shop/credits/purchase |
| Ledger | fetchShopWalletTransactions | GET /shop/transactions |
| Kiosk domain | fetch/create/patchShopKioskDomain | /shop/kiosk-domain (GET/POST/PATCH) |
| Thông báo | fetchShopBroadcasts, createShopInAppPopup | /shop/broadcasts, /shop/in-app-popups |
| Payment 0x | useSupportedZeroxMethods | GET /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í).
- 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)
- Redux / Zustand / Jotai
- React Query / SWR
- Global wallet/player store (mỗi trang tự fetch)
- Alias path
@/*(import tương đối)
| Sự kiện | Ai dispatch | Ai lắng nghe / tác dụng |
|---|---|---|
auth:unauthorized | response interceptor (401) | AuthProvider → logout() |
shop-auth-token-changed | login() / logout() | ShopInAppPopupGate refetch popup |
shop-credit-balance-refresh | dispatchShopCreditBalanceRefresh() sau khi nạp/return payment | AppLayout refetch số dư sidebar |
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.
| Status | Ý nghĩa | Hành động UI |
|---|---|---|
not_found | Chưa có tài khoản với số này | Nút "Send OTP — create & link" → luồng đăng ký |
unassigned | Có tài khoản nhưng chưa thuộc shop nào | Nút "Send OTP — link to this shop" → luồng đăng ký |
in_shop | Đã thuộc shop này | Không cần OTP; onInShop(phone) → focus search trong bảng |
other_shop | Đang thuộc shop khác | Chuyển sang luồng transfer (player phải duyệt OTP) |
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.
| otpFlowMode | Khi nào | API khởi tạo | API verify |
|---|---|---|---|
registration | not_found / unassigned | registerPlayerInitiate({phone,email?,verification_method}) | verifyPlayerRegistration(id, otp) |
transfer | other_shop | initiatePlayerTransfer(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.
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.
// 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; }
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.
Input lọc replace(/\D/g,''). estimatedCharge = creditNum × safeRate (rate kẹp về [0,1]).
Recommend = các method 0x (useSupportedZeroxMethods('deposit'), có cache 1 ngày). Other = btcpay/linkmepay (BTC, LTC, DOGE, PYUSD, Cash App).
zerox_*: $5–$10,000. cashapp: chỉ các mức trong ECASH_APP_ALLOWED_AMOUNTS. Khác: tối thiểu $10.
purchaseShopCredits(creditNum, mappedMethod) → POST /shop/credits/purchase. mapUiPaymentIdToAgentPaymentMethod đổi id UI sang chuỗi backend (vd btcpay→btc_onchain).
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.
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ề.
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.
// 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] || ''; }
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.
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%).
@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ến | Mặc định | Ý nghĩa |
|---|---|---|
REACT_APP_API_BASE_URL | http://localhost:3001/api | Base URL (đã gồm /api) |
REACT_APP_APP_NAME | Kiosk Gaming — Shop | Tên app |
REACT_APP_TURNSTILE_ENABLED | false | 'true' mới bật captcha login |
REACT_APP_TURNSTILE_SITE_KEY | — | Site key Cloudflare Turnstile |
PORT | 3004 | Cổng dev server (CRA) |
Ở đâ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 có typecheck — khác kiosk frontend bỏ qua lỗi TS).
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
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.
- Thêm hàm async trong
src/services/api.tsdùngclient.get/post/patch(đường dẫn bắt đầu/shop/..., KHÔNG thêm/api). - Khai interface response trong
src/types/index.ts(chịu cả camelCase/snake_case nếu cần). - Trong page/component, tự quản
loading/errorbằnguseState+useCallback; báo lỗi quatoast.
- Tạo
src/pages/MyPage.tsx, bọc nội dung trong<AppLayout>. - Khai
<Route>trongApp.tsx, bọc<ProtectedRoute>nếu cần auth. - Nếu muốn vào sidebar, thêm mục vào
NAV_ITEMStrongAppLayout.tsx(đặtprimary: trueđể hiện cả ở bottom nav mobile).
- Sau khi nạp/transfer thành công, gọi
dispatchShopCreditBalanceRefresh()(từconstants/). AppLayoutđang lắng nghe sự kiện này → tự gọi lạifetchDashboard()cho card số dư sidebar.- 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).
- Mọi logic lookup/OTP nằm trong
ShopPlayerLinkByPhonePanel.tsx— sửa ở đây, đừng nhân bản. - Nhớ xử lý cả 4
lookupStatusvà cả 2otpFlowMode. - Modal ở
PlayersPagedùngkeytă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ẫy | Chi tiết & cách xử lý |
|---|---|
Double /api | API_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 nhau | Sidebar 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 casing | Field 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ệt | ShopCreditPurchaseModal 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 ticker | Chỉ BTC & USDT* hiện ra (filterShopVisiblePurchaseOptions). Method khác bị ẩn dù API trả về. |
| SHOP_FRONTEND_URL / SHOP_KIOSK_BASE_DOMAIN | Hai 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ó test | npm 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. |