B2B Portal React 18 · CRA + Craco Capacitor 7 (Android APK) TypeScript 4.9 kioskgaming_shop/

Shop Portal
(Cổng Quản Lý Cửa Hàng)

Giao diện B2B cho chủ cửa hàng gaming kiosk. Quản lý player, cashier/shift, nạp/rút credits, ví USD online, kiosk domain, counter deposits/redemptions — trong chuỗi SuperAgent → Agent → Shop → Cashier → Player.

25+
Routes bảo vệ
2
Ví (Credit + USD)
/shop/*
API prefix
$50
Min low-balance alert
🏪
Vai trò Cửa Hàng trong Hệ thống

Shop nằm ở tầng giữa của hệ thống phân cấp. Họ mua credits từ Agent cấp trên và phân phối xuống Player tại địa điểm kinh doanh thực tế.

Tầng 1
Super Agent
Quản lý toàn hệ thống, phân phối credit cho Agent
Tầng 2
Agent
Cung cấp credits cho nhiều Shop, thiết lập cost rate
Tầng 3 — BẠN ĐÂY
Shop (Cửa Hàng)
Quản lý một địa điểm kiosk, liên kết và nạp credits cho Player
Tầng 4
Player
Khách hàng chơi game tại kiosk của cửa hàng
Ví Shop Wallet

Mỗi shop có một ví credit riêng. Credit mua từ Agent, sau đó phân phối cho Player khi họ chơi game. Số dư hiển thị trên Dashboard với cảnh báo low-balance.

Cost Rate từ Agent

Mỗi shop nhận một cost_rate do Agent thiết lập. Ví dụ: rate 0.85 nghĩa là mua 100 credits sẽ tốn $85 USD. Rate hiển thị trên Dashboard card.

Kiosk Domain riêng

Mỗi shop được cấu hình một subdomain riêng tại tenShop.kioskgaming.com — Player truy cập URL này để chơi game tại địa điểm cửa hàng.

📊
Dashboard — Tổng quan Cửa Hàng

DashboardPage.tsx — Trang chủ hiển thị 4 stat card chính và danh sách giao dịch gần đây.

Stat Card Icon Giá trị Loại
Shop wallet Coins Credit balance (2 chữ số thập phân) purchase — mở modal mua credits
Players Users Số player đã liên kết link/players
Cost rate Percent Tỷ lệ % từ Agent (ví dụ: 85.00%) plain — chỉ xem
Kiosk URL Globe "Setup" hoặc subdomain hiện tại link/kiosk-domain
💡
Auto refresh sau thanh toán

Dashboard sử dụng location.state.openCreditPurchase để mở modal mua credits khi điều hướng về từ trang ShopCreditPurchaseModal. Sau khi đặt hàng xong, dashboard tự refresh balance trong nền (silent mode, không hiện loading spinner).

Components trên Dashboard
  • ShopCreditPurchaseTrigger — nút mở credit purchase với tone màu orange
  • ShopDashboardOverview — thông tin shop (name, email, status)
  • ShopHomeRecentTransactions — lịch sử giao dịch gần đây (appearance="dashboard")
👥
Quản lý Player

PlayersPage.tsx — Danh sách player, tìm kiếm, sắp xếp, phân trang. Hai modal chính: Create Player và Transfer from Another Shop.

Bảng danh sách Player
  • Responsive: card view trên mobile, table trên desktop (≥md)
  • Search: debounce 300ms, tìm theo phone hoặc email
  • Sort: shop_assigned_at (mặc định, desc) hoặc name
  • Phân trang: 10/20/50 rows, hiển thị tổng số player
  • Click username → /players/:playerId
  • Status badge: active (emerald) hoặc khác (gray)
Hai nút hành động chính
  • Create player — mở modal tạo mới hoặc liên kết player chưa có shop (flow="create")
  • Transfer from another shop — mở modal chuyển player đang ở shop khác sang (flow="transfer")
  • Mỗi modal dùng ShopPlayerLinkByPhonePanel với prop flow khác nhau
  • Modal key được reset khi mở lại để clear state cũ
  • Hai flow có thể switch lẫn nhau qua callback onSwitchFlow

ShopPlayerLinkByPhonePanel — Lõi logic liên kết player

Component dùng chung cho cả hai flow. Tích hợp react-phone-number-input để nhập số điện thoại quốc tế với country picker.

not_found
Không có tài khoản nào với số điện thoại này trong hệ thống.
→ Send OTP to create & link — tạo player mới và liên kết ngay
in_shop
Player đang thuộc cửa hàng này. Có thể nạp credits ngay.
→ Hiển thị phone trên bảng, không cần OTP
unassigned
Player tồn tại nhưng chưa được gán cho shop nào.
→ Send OTP to link to this shop
other_shop
Player đang thuộc shop khác. Cần xác nhận chuyển.
→ Send OTP to transfer to this shop (player confirm)

OTP Registration Flow (Create Player)

Bước 1
Nhập phone
react-phone-number-input, international format
Bước 2
Lookup
lookupPlayerByPhone() → trả về status
Bước 3
Initiate OTP
registerPlayerInitiate() → nhận registration_id
Bước 4
Nhập OTP
6 chữ số, numeric only, autocomplete="one-time-code"
Bước 5
Verify
verifyPlayerRegistration() → player linked

Hỗ trợ resend OTP với cooldown timer (nextResendAt) và resendCount tracking. Verification method: SMS hoặc email.

PlayerDetailPage — Chi tiết Player

/players/:playerIdPlayerDetailPage.tsx — Xem và quản lý từng player. Tích hợp PlayerDetailPanel component cho UI chi tiết.

  • Load player data và shop credit balance song song
  • Dispatch shopCreditBalanceRefresh custom event sau mỗi thao tác credit
  • 404 empty state nếu playerId không hợp lệ hoặc không thuộc shop
  • Breadcrumb back → /players
🔄
Chuyển Player & Nạp Credits — TransferToPlayerPage
⚠️
Lưu ý về route

/transfer redirect đến /players (legacy). Trang TransferToPlayerPage.tsx hiện ở /transfer/legacy — chức năng này được tích hợp trực tiếp vào modal của PlayersPage thông qua ShopPlayerLinkByPhonePanel.

Trang /transfer/legacy là màn hình tổng hợp: lookup player bằng phone, liên kết/tạo player (OTP), và nạp credits cho player đã có trong shop.

OTP Transfer Flow (Chuyển từ Shop khác)
  • Lookup phone → status = other_shop
  • initiatePlayerTransfer(playerId) → nhận transfer ID
  • OTP gửi đến phone của player (họ phải xác nhận)
  • verifyPlayerTransfer(transferId, otp) → player chuyển sang shop này
  • Sau verify, lookup lại để cập nhật trạng thái UI
Nạp Credits cho Player (in_shop)
  • Chỉ hoạt động khi player status = in_shop
  • Nhập amount (number input, step=any) và payment method
  • Payment method: cash hoặc bank transfer
  • Confirmation modal với bảng chi tiết (balance before/after)
  • Kiểm tra insufficient funds ngay trên form
  • postShopPlayerCredits(playerId, {action:'add', amount, payment_method})

Hai loại OTP trong TransferToPlayerPage

otpKindKhi nàoAPI khởi tạoAPI verify
create Player chưa tồn tại (not_found) hoặc chưa có shop (unassigned) registerPlayerInitiate(phone) verifyPlayerRegistration(id, otp)
assign Player đang ở shop khác (other_shop) initiatePlayerTransfer(playerId) verifyPlayerTransfer(id, otp)
📱
Pre-fill từ Players table

Khi điều hướng đến /transfer/legacy với location.state.prefillPhone, trang tự động điền số điện thoại và chạy lookup ngay. Sau đó clear state để tránh prefill lại khi refresh.

🌐
Kiosk Domain — Cấu hình Subdomain

KioskDomainPage.tsx/kiosk-domain — Mỗi cửa hàng có một URL kiosk riêng dạng tenShop.kioskgaming.com. Trang này cho phép tạo mới hoặc thay đổi subdomain.

Ba trạng thái UI

1. Chưa có domain (tạo mới)

Form với input "Subdomain label", preview URL live, nút "Create kiosk URL". Validation: chỉ chấp nhận lowercase, số, gạch ngang.

2. Đã có domain (read-only)

Hiển thị URL hiện tại dưới dạng https://preview. Nút "Edit" (Pencil icon) chuyển sang form sửa.

3. Đang sửa domain

Form thay đổi subdomain với warning "Changing the URL may break old bookmarks until DNS updates propagate." Có nút Cancel và Save.

API & Logic

HàmMô tả
fetchShopKioskDomain()Lấy domain hiện tại + base domain
createShopKioskDomain(label)Tạo subdomain lần đầu
patchShopKioskDomain(label)Cập nhật subdomain

Hàm subdomainLabelFromFqdn(fqdn, base) extract phần label từ full domain. Ví dụ: mystore.kioskgaming.commystore. Preview URL được render live khi nhập.

⚠️
SHOP_KIOSK_BASE_DOMAIN

Nếu server không config env var này, trang hiển thị warning amber và không cho phép tạo domain.

💳
Mua Credits — ShopCreditPurchaseModal

ShopCreditPurchaseModal.tsx/purchase/checkout — Giao diện dark theme mua credits từ Agent/platform. Shop nhập số credits cần mua và chọn phương thức thanh toán.

Tính toán chi phí
  • Credits × cost_rate = estimated charge (USD)
  • Balance hiển thị: current, và new balance sau khi mua
  • Rate hiển thị % bên cạnh estimated charge
  • Input: chỉ nhận số nguyên (replace non-digits)
  • Auto-refresh balance khi tab được focus lại sau >2 giây
Hai tab phương thức thanh toán
  • Recommend tab — 0x (zerox) deposit methods, tải dynamic từ API
  • Other tab — các phương thức khác (cash app, v.v.)
  • Search filter theo name/ticker/network/description
  • Pagination: PAYMENT_OPTIONS_PER_PAGE mỗi trang
  • Coming soon cards bị disable với opacity 50%

Validation theo phương thức

Phương thứcĐiều kiện
zerox_* (0x deposit) Amount: $5 – $10,000 USD. Ngoài range → error message
cashapp (ECash App) Chỉ nhận các amount trong ECASH_APP_ALLOWED_AMOUNTS cố định
Các phương thức khác Minimum $10.00 USD (không có upper limit)
Bước 1
Nhập credits
Số nguyên, tính estimated charge USD
Bước 2
Chọn tab
Recommend (0x) hoặc Other
Bước 3
Chọn method
PaymentMethodCard với icon, ticker
Bước 4
Continue to payment
purchaseShopCredits() → nhận paymentUrl
Bước 5
Thanh toán
iframe embed + "Open in new tab" button
Sau khi tạo đơn thành công

Kết quả hiển thị: credit amount, amount to pay (currency formatted), transaction ID (order reference). Payment URL được nhúng trong iframe với sandbox permissions phù hợp. Nút "New purchase" reset form nhưng giữ modal mở.

🔐
Bảo mật OTP — Xác thực hai bước

OTP là cơ chế bảo mật cốt lõi cho mọi thao tác liên kết và chuyển player. Đảm bảo sự đồng ý của player trước khi thay đổi shop assignment.

Các trường hợp kích hoạt OTP

Tình huốngOTP gửi đếnMục đích
Tạo player mới (not_found) Số phone nhập vào Xác minh sở hữu phone
Liên kết player vô chủ (unassigned) Phone của player Đồng ý liên kết shop
Chuyển từ shop khác (other_shop) Phone của player Player xác nhận rời shop cũ

Đặc điểm kỹ thuật OTP input

  • Chỉ nhận số: replace(/\D/g, '')
  • Giới hạn 6 ký tự: .slice(0, 6)
  • Tracking widest font để dễ đọc
  • autocomplete="one-time-code" để browser autofill
  • inputMode="numeric" để bàn phím số trên mobile
  • Resend OTP: cooldown timer qua nextResendAt
  • Resend count tracking (resendCount)
  • Verification method: SMS hoặc email (chọn khi tạo)
🔒
Nạp credits KHÔNG cần OTP

Khi player đã ở trong shop (in_shop), việc nạp credits chỉ cần qua confirmation modal — không cần OTP. OTP chỉ dùng cho các thao tác thay đổi shop assignment (create, link, transfer).

✉️
Gửi Thông báo — Broadcast & Popup
SendBroadcastPage — /broadcasts/send
  • Gửi bell notification đến player trong shop
  • Tiêu đề tối đa 500 ký tự, body tối đa 1000 ký tự
  • Chọn player (multi-select, chỉ active)
  • Tùy chọn "Hide after" (datetime expiry)
  • Preview real-time trước khi gửi
  • Guard: kiểm tra can_create_player_popup từ dashboard data
SendPlayerPopupPage — /popups/send
  • Gửi in-app popup đến player
  • Display mode: once hoặc sticky_daily
  • Cùng guard can_create_player_popup
  • Tiêu đề & body tương tự broadcast
  • Tùy chọn expiresAt
  • Multi-select player từ active list

⚙️
Settings — Cảnh báo Số dư Thấp

SettingsPage.tsx/settings — Chức năng duy nhất: cấu hình low balance alert.

Low Balance Warning
  • Toggle enable/disable cảnh báo
  • Ngưỡng cảnh báo tối thiểu: $50 USD (MIN_LOW_BALANCE_USD = 50)
  • Input step=0.01, min=$50
  • Lưu qua patchShopLowBalanceWarning({low_balance_warning_enabled, low_balance_threshold_usd})
  • Load từ fetchDashboard() — trường low_balance_warning_enabledlow_balance_threshold_usd

💰
Transactions — Lịch sử Giao dịch

TransactionsPage.tsx/transactions — Danh sách giao dịch ví shop. Hiển thị tiền vào/ra từ góc nhìn shop.

Bộ lọc
  • Search: theo ID, title, counterparty, referenceId, metadata (JSON search)
  • Method filter: all / cash / card
  • Load 200 giao dịch mới nhất, sort desc
  • Hook useShopWalletTransactions
Hiển thị
  • Amount format: +/ theo góc nhìn shop
  • Balance = số dư shop ngay sau giao dịch
  • Helpers: getPresentation(), normalizeMeta(), txCreatedAt()
  • Empty state khi không có giao dịch nào
↩️
Payment Return Pages

ShopPaymentReturnPage.tsx — Ba trang trả về sau khi thanh toán qua provider bên ngoài.

RouteComponentTrạng thái
/payment/success ShopPaymentSuccessPage Thanh toán thành công
/payment/cancelled ShopPaymentCancelledPage Người dùng hủy
/payment/error ShopPaymentErrorPage Lỗi thanh toán
⚙️
Technical — React 18 · CRA + Craco

Dependencies chính

React 18.2 TypeScript 4.9 React Router v6.8 Tailwind CSS 3.2 react-hook-form 7.43 react-hot-toast 2.4 react-phone-number-input 3.4 axios 1.3 lucide-react 0.263 @marsidev/react-turnstile

Internal packages

@kioskgaming/ui @kioskgaming/page-loading

Workspace packages: file:../packages/uifile:../packages/page-loading

Cấu trúc thư mục

kioskgaming_shop/src/
├── pages/          # 11 page components
│   ├── DashboardPage.tsx
│   ├── PlayersPage.tsx
│   ├── PlayerDetailPage.tsx
│   ├── KioskDomainPage.tsx
│   ├── ShopCreditPurchaseModal.tsx
│   ├── TransactionsPage.tsx
│   ├── TransferToPlayerPage.tsx
│   ├── SendBroadcastPage.tsx
│   ├── SendPlayerPopupPage.tsx
│   ├── SettingsPage.tsx
│   └── ShopPaymentReturnPage.tsx
├── components/
│   ├── auth/          # LoginPage, ProtectedRoute
│   ├── credits/       # ShopCreditPurchaseTrigger
│   ├── dashboard/     # ShopDashboardOverview
│   ├── home/          # ShopHomeRecentTransactions
│   ├── layout/        # AppLayout
│   ├── players/       # PlayerDetailPanel
│   ├── ui/            # PortalEmptyState
│   ├── ShopPlayerLinkByPhonePanel.tsx
│   ├── ShopNotificationBell.tsx
│   └── ShopInAppPopupGate.tsx
├── services/
│   └── api.ts         # 30+ exported API functions
├── hooks/
│   ├── useAuth.tsx
│   ├── useShopWalletTransactions.ts
│   └── useSupportedZeroxMethods.ts
├── types/          # TypeScript type definitions
├── utils/
│   ├── phone.ts        # isPlausibleInternationalPhone()
│   └── shopWalletLedger.ts
└── constants/      # dispatchShopCreditBalanceRefresh

Toàn bộ Routes

PathComponentGhi chú
/DashboardPageStat cards + recent transactions
/playersPlayersPageList + create/transfer modals
/players/:playerIdPlayerDetailPageChi tiết player cụ thể
/transactionsTransactionsPageWallet ledger 200 giao dịch
/purchase/checkoutShopCreditPurchaseModalDark UI, 0x payments + other
/kiosk-domainKioskDomainPageSubdomain create/edit
/settingsSettingsPageLow balance alert settings
/popups/sendSendPlayerPopupPagePermission-gated
/broadcasts/sendSendBroadcastPageBell notifications
/transfer/legacyTransferToPlayerPageLegacy, được embed vào Players modals
/transferRedirect → /players
/purchaseRedirect → /
/payment/successShopPaymentSuccessPageReturn từ payment provider
/payment/cancelledShopPaymentCancelledPageReturn từ payment provider
/payment/errorShopPaymentErrorPageReturn từ payment provider
/loginLoginPageAuth không cần ProtectedRoute
*Catch-all redirect → /

Patterns đáng chú ý

Custom Event Bus

dispatchShopCreditBalanceRefresh() từ constants/ — emit custom DOM event để sync credit balance giữa components không có quan hệ cha-con trực tiếp.

visibilitychange refresh

ShopCreditPurchaseModal lắng nghe document.visibilitychange — nếu tab ẩn >2 giây rồi focus lại, tự động refresh balance (giả định người dùng vừa thanh toán ở tab khác).

Modal Key Reset

Mỗi lần mở modal tạo/transfer player, key tăng lên 1 (setModalKey(k => k+1)). React unmount/remount component để clear toàn bộ state form.