Technical details —Shop Portal
Deep dive into the execution mechanism of the store management portal: CRA + React Router architecture, axios service layer, JWT auth flow, player lookup state machine, two OTP flows, and how credit/USD wallets flow in the B2B Agent→Shop→Cashier→Player chain. Read the following overview report07-shop.html.
Stack & holistic thinking
Before reading the code, understand the 6 most "different" features of this app - especially compared to the Kiosk Frontend app (Next.js) described in report 02. Once you understand them, you will read the code very quickly.
True multi-page B2B App
Unlike a kiosk (one-screen state machine), this is a CRA portal used by many routesreact-router-domv6. Has sidebar, bottom nav mobile, 16+ routes — navigation using real URL.
Useaxios, not fetch
An internal singleton axios instanceservices/api.tswith the interceptor attaching the Bearer token and catching 401. All ~35 API functions exported from this file.
State = Context auth + local useState
Only1 React Context (AuthProvider). No Redux/Zustand/React Query. Each page is fetched by itselfuseState + useCallback + 2 custom hook.
There is a real route guard
All routes (except/login) wrap<ProtectedRoute>— redirect to login if not auth. This is a big difference compared to the frontend kiosk (no guard).
Synchronize using custom DOM events
There is no global store, the app uses itwindow.dispatchEvent: auth:unauthorized, shop-auth-token-changed, shop-credit-balance-refreshso that separate components can update together.
Two types of "money" need to be distinguished
Credits(For shop, buy from Agentcost_rate) andUSD(the actual amount must be paid via payment provider). Player receives credits from shop wallet.
Shop in the B2B chain: Agent → Shop → Cashier → Player
Shop is the intermediate floor:receive credits from Agent(according to exchange rate), yesDistribute credits to Playerat the place of business. Cashier is the operator at the counter. Earning USD comes from the price difference when depositing players.
Credits stream in
Shop mua credits via POST /shop/credits/purchase. USD price =credits × cost_rate. cost_rate(0–1) set by Agent, stored in shop profile.
The credits flow out
Reload credits for playersPOST /shop/players/:id/credits (action: 'add'). Immediately deducted from the shop wallet. Player plays games using these credits.
Wallet & ledger
The shop wallet is displayed in the sidebar + dashboard. All changes are recordedwallet_transactions (see GET /shop/transactions), enrich via presentation.
Newbies should open:src/App.tsx (routes) → services/api.ts(entire API) →hooks/useAuth.tsx (auth) → components/ShopPlayerLinkByPhonePanel.tsx(player business core + OTP) →pages/ShopCreditPurchaseModal.tsx(credit purchasing stream). These ~5 files are 70% of the system.
Directory structuresrc/
Flat folder tree in CRA style. Convention: page PascalCase inpages/, shared components incomponents/, no alias@/*— import using relative path.
Startup Screen & render model
It is a pure client CRA SPA (no SSR).index.tsx render <App/> in StrictModeand register a service worker (PWA).
ReactDOM.createRoot(#root).render(<App/>) + registerServiceWorker(). Global CSS fromindex.css.
<AuthProvider>wrap<BrowserRouter> + <Routes> + <Toaster/>(react-hot-toast, top right corner).
useEffectreadshop_auth_token + shop_user_datafrom localStorage. While reading,isLoading=true→ ProtectedRoute shows spinner.
If authenticated →DashboardPage(wrapAppLayout). Not yet authenticated → redirect/loginattachedstate.fromto return after logging in.
Let the auth context exist independently of the router, and let the interceptor 401 (dispatchauth:unauthorized) can triggerlogout()no matter which route the user is on.
Routing & ProtectedRoute
All routes declared flat inApp.tsx. No route group, no nested layout (layout is on each page<AppLayout>). All routes except/loginevenly wrapped<ProtectedRoute>.
| Path | Component | Guard | Note |
|---|---|---|---|
/login | LoginPage | — | Public; if auth → redirect/ |
/ | DashboardPage | ✓ | Stat cards + overview |
/players | PlayersPage | ✓ | List + modal create/transfer |
/players/:playerId | PlayerDetailPage | ✓ | Player details |
/transactions | TransactionsPage | ✓ | Ledger wallet shop |
/cashiers | CashiersPage | ✓ | Cashier management |
/shifts | ShiftsPage | ✓ | Shift |
/cash-settlement/deposits | CounterDepositsLedgerPage | ✓ | Load counters |
/online-transactions | OnlineBalanceTransactionsPage | ✓ | USD wallet |
/withdrawals | WithdrawalsApprovalPage | ✓ | Browse and withdraw players |
/purchase | redirect / | — | Buy via modal |
/purchase | — | — | Redirect → / |
/kiosk-domain | KioskDomainPage | ✓ | Subdomain |
/settings | SettingsPage | ✓ | Low balance warning |
/popups/send | SendPlayerPopupPage | ✓ | Send in-app popup |
/broadcasts/send | SendBroadcastPage | ✓ | Send bell |
/transfer | — | ✓ | Redirect → /players |
/transfer/legacy | TransferToPlayerPage | ✓ | Trang transfer legacy |
/payment/success|cancelled|error | ShopPayment*Page | ✓ | Return from payment provider |
* | — | — | Catch-all → / |
const { isAuthenticated, isLoading } = useAuth(); const location = useLocation(); if (isLoading) return <PageLoadingIndicator variant="section" />; // wait for session recovery if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
ProtectedRoutejust block the UI. Every endpoint/shop/*still have to manually validate the JWT on the server side. Don't consider guard FE as a real security layer.
Login flow & JWT
1-step login: email + password (+ optional Turnstile). JWT token stored in localStorage. Shop sessioncompletely separatewith the Admin system (private key, no cookies shared).
react-hook-formvalidate email + password (≥6 characters). IfTURNSTILE_ENABLED→ captcha token required.
loginWithEmailPassword() → POST /shop-auth/loginwith{ email, password, captchaToken? }.
toShopProfile(res.data.shop)map both camelCase & snake_case (costRate/cost_rate, agentId/agent_id).
Ghi shop_auth_token + shop_user_dataGo to localStorage, set context, dispatchshop-auth-token-changed.
navigate(from)— return to previously blocked route (taken fromlocation.state.from), default/.
Save tokens
localStorage: shop_auth_token(onlyaccessToken), shop_user_data(JSON profile). Interceptor mountedAuthorization: Beareron every request.
Token expiration
When receiving 401, interceptor dispatchauth:unauthorized → AuthProvidercalllogout()→ delete token + return to login.Are notThere is a refresh mechanism.
ShopLoginResponse.datayesaccessToken & refreshToken, but clientsave only accessToken. There is no proactive or reactive refresh flow — the accessToken expires and you are logged out. If you need a long session, you must add refresh logic yourself.
services/api.ts— the brain communicates
A single file exports ~35 async functions, all sharing the same axios client. Components don't call axios themselves — they import specific functions.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'— note the prefix/apialready in the base, the endpoint is just added/shop/... - Timeout:15s; default headers
Content-Type: application/json - Request interceptor:read
shop_auth_token→ mountAuthorization: Bearer - Response interceptor:If
401→window.dispatchEvent('auth:unauthorized')then reject - Standard envelope:
{ success, message?, data?, code? }— many additional pay functionspagination
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); });
The main endpoint group (prefix base already exists/api)
| Group | Typical function | 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 registration | 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 |
| Buy credits | purchaseShopCredits | POST /shop/credits/purchase |
| Ledger | fetchShopWalletTransactions | GET /shop/transactions |
| Kiosk domain | fetch/create/patchShopKioskDomain | /shop/kiosk-domain (GET/POST/PATCH) |
| Notification | fetchShopBroadcasts, createShopInAppPopup | /shop/broadcasts, /shop/in-app-popups |
| Payment 0x | useSupportedZeroxMethods | GET /payment/supported-methods |
State management & Synchronize via custom events
Because there is no global store, the app uses itDOM CustomEventcreate an "event bus" so that non-parent-child components can update at the same time (especially wallet balance).
- 1 Context:
AuthProvider(user, token, isAuthenticated, isLoading) - 2 custom hook:
useShopWalletTransactions,useSupportedZeroxMethods(has 1 day localStorage TTL cache) - 3 DOM events: see table below
- Toast:
react-hot-toast(Toaster mount in App.tsx)
- Redux / Zustand / Jotai
- React Query / SWR
- Global wallet/player store (each page is fetched by itself)
- Alias path
@/*(relative import)
| Event | Ai dispatch | Who listens/effects |
|---|---|---|
auth:unauthorized | response interceptor (401) | AuthProvider → logout() |
shop-auth-token-changed | login() / logout() | ShopInAppPopupGate refetch popup |
shop-credit-balance-refresh | dispatchShopCreditBalanceRefresh()after deposit/return payment | AppLayout refetch sidebar balance |
In addition to the event bus,AppLayout & ShopCreditPurchaseModalstill listeningdocument.visibilitychange: if hidden tab > 2 seconds then return (assuming the user just paid in another tab) then call itselffetchDashboard()to update balance.
Look up players by phone number — 4 statuses
The business core lies inShopPlayerLinkByPhonePanel.tsx— common to the "Create player" modal (flow="create") and "Transfer from another shop" (flow="transfer"). User enters phone (viareact-phone-number-input), click lookup, the server returns one of 4 statuses to decide the next action.
| Status | Meaning | UI actions |
|---|---|---|
not_found | There is no account with this number yet | "Send OTP — create & link" button → registration flow |
unassigned | Have an account but don't belong to any shop yet | "Send OTP — link to this shop" button → registration flow |
in_shop | Belongs to this shop | No OTP needed;onInShop(phone)→ focus search in the table |
other_shop | Belongs to another shop | Switch to transfer stream (player must approve OTP) |
Even while stayingflow="create", callregisterPlayerInitiatecan receivestatus: 'transfer_required'(attachedplayer_id) — either via body 2xx or via error 4xx. The code handles both: setlookupStatus='other_shop'and suggests users change to the Transfer tab. This is an easy point to miss when reading quickly.
Two OTP streams: registration vs transfer
OTP ensures the player's consent before changing the shop assignment. The same panel manages two streams using variablesotpFlowMode ('registration' | 'transfer') with 2 different ids:registrationIdortransferId.
| otpFlowMode | When | Initialization API | API verify |
|---|---|---|---|
registration | not_found / unassigned | registerPlayerInitiate({phone,email?,verification_method}) | verifyPlayerRegistration(id, otp) |
transfer | other_shop | initiatePlayerTransfer(playerId) | verifyPlayerTransfer(id, otp) |
Specification of OTP input box
6 numbers, character filtering:value.replace(/\D/g,'').slice(0,6). inputMode="numeric", autocomplete="one-time-code"let mobile autofill.
Resend & cooldown
Cooldown via nextResendAt(countdown every second equalssetInterval). Max5 times resend; pass → restart. First time setting+60s client-side.
OTP Channel & verify (flow create only)
When creating a player, you can choose a channelsmsoremail(if email → email is required). After successful verification at flow registration, if response is returneddata.playerthen callonLinkedPlayer()to navigate to the details page. With flow transfer, the panel remainslookup againphone to pick upplayerIdnew then navigation.
OTP only applies to shop assignment changes (create/link/transfer). When the player hasin_shop, loading credits (postShopPlayerCredits action add) only via confirmation modal — no OTP.
Credit wallet, cost rate & ledger
The shop's wallet is calculated incredits. The balance is shown in the sidebar (AppLayout) and 4 Dashboard stat cards. All fluctuations read fromGET /shop/transactionsand is "presented" via internal helperutils/shopWalletLedger.ts.
cost_rate
The number 0–1 is set by the Agent.fetchDashboard().cost_rateor fallbackuser.costRate. Show % passed(rate*100). USD payable =credits × rate.
Balance warning
Sidebar uses thresholdhardcode 100 credits (LOW_SHOP_CREDIT_BALANCE_THRESHOLD). Settings also configure a separate USD threshold (minimum $50). Two different numbers!
Ledger presentation
getPresentation(row) merge field presentationfrom server with counterparty parse frommetadata; self-reflectiondirection(in/out) wordsbalanceBefore/After.
// Match backend normalizeLedgerMetadata: metadata can be 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; // normalize keys return o; }
ShopWalletLedgerRowParallel declarationbalanceBefore/balance_before, costRate/cost_rate… because the API has a place to return camelCase, a place to return snake_case (and a place to put it inmetadata). When reading values, always use helper (toNum, getPresentation) instead of accessing it directly.
ShopCreditPurchaseModal & payment provider
The dark UI page allows the shop to buy more credits from the platform. Enter number of credits → calculate estimated USD → select method → create order → receivepaymentUrlembed iframe.
Filtered inputreplace(/\D/g,''). estimatedCharge = creditNum × safeRate(rate clamped to [0,1]).
Recommend= methods 0x (useSupportedZeroxMethods('deposit'), with 1 day cache).Other = btcpay/linkmepay (BTC, LTC, DOGE, PYUSD, Cash App).
zerox_*: $5–$10,000. cashapp: indicates internal levelsECASH_APP_ALLOWED_AMOUNTS. Other: minimum $10.
purchaseShopCredits(creditNum, mappedMethod) → POST /shop/credits/purchase. mapUiPaymentIdToAgentPaymentMethodchange UI id to backend string (egbtcpay→btc_onchain).
Presentlyresult.paymentUrlin iframe (sandbox) + "Open in new tab" button. Adding credits is handled by the webhook backend after the provider confirms.
filterShopVisiblePurchaseOptionsjust for showBTCand any variationsUSDT (regex /^USDT($|[\s(])/, matches "USDT (TRC20)"). Other methods are hidden even though the API returns them.
Provider redirects back/payment/success|cancelled|error. Backend needs to be placedSHOP_FRONTEND_URLPoints to the correct origin of this app (dev:http://localhost:3004), otherwise users will miss the page after payment.
Configure subdomain for location
Each shop has a kiosk URL formlabel.<base>. KioskDomainPageThere are 3 statuses: not yet available (created), available (read-only + Edit), under editing.
// Separate label from 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()paykiosk_base_domain. If empty (server has not configured envSHOP_KIOSK_BASE_DOMAIN), the page shows an amber warning and does not allow domain creation. Preview URL renders live as you type.
Broadcast bell & in-app popup gate
Medium shopreceiveNotification from Admin/Agent (bell + popup), mediumsendNotification to player (broadcast + popup).
ShopNotificationBell (receive)
Poll GET /shop/broadcastseach2 minutes. Unread Count Badge; click 1 item →ackShopBroadcast(id). Close dropdown when clicked outside.
ShopInAppPopupGate (get)
createPortal ra document.body. Fetch /shop/in-app-popups/unseen, queue popup. Mode once→ "OK" calls mark-seen;sticky_daily→ added "Don't show again".
SendBroadcastPage (send)
Send bell to player (multi-select active). Title ≤500, body ≤1000 characters. Guardcan_create_player_popupfrom dashboard.
SendPlayerPopupPage (send)
Send in-app popup,displayMode: once | sticky_daily, optionalexpiresAt. Same guardcan_create_player_popup.
Both bell and popup render body throughBroadcastMessageBody(parse markup link fromutils/broadcastLinkMarkup.ts) — the link can be opened, yesstopPropagationto not trigger "mark as read" when clicking the link.
Tailwind 3 + workspace packages
Different from kiosk frontend (Tailwind v4 without configuration), this app uses itTailwind 3.2 has a config filewith HSL color token declared insideindex.css. The main tone is emerald (primary160 84% 39%).
@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --primary: 160 84% 39%; /* emerald — used via hsl(var(--primary)) */ --border: 160 10% 90%; --radius: 0.5rem; } } // tailwind.config.js — content scans the built ui package content: ['./src/**/*.{js,jsx,ts,tsx}', '../packages/ui/dist/**/*.js']
Internal packages
@kioskgaming/ui & @kioskgaming/page-loading (workspace file:../packages/*). UsePageLoadingIndicator/PageLoadingSpinnereverywhere.
Icon & form
Icon: lucide-react. Form login: react-hook-form. Phone: react-phone-number-input (style override in index.css class .transfer-phone-field).
Toast & captcha
Toast: react-hot-toast. Captcha: @marsidev/react-turnstile(enable according to env).
Config & environment variable
Read through with concentrationsrc/config/env.ts. Variables must have a prefixREACT_APP_(CRA convention). Available.env.example.
| Variable | Default | Meaning |
|---|---|---|
REACT_APP_API_BASE_URL | http://localhost:3001/api | Base URL (included/api) |
REACT_APP_APP_NAME | Kiosk Gaming — Shop | App name |
REACT_APP_TURNSTILE_ENABLED | false | 'true'Just enabled captcha login |
REACT_APP_TURNSTILE_SITE_KEY | — | Site key Cloudflare Turnstile |
PORT | 3004 | Dev server port (CRA) |
Here/apiwas insideAPI_BASE_URLso the endpoint only writes/shop/.... Don't add/apimanually again when writing new functions.
Testing & build
Test infrastructure is CRA's default (react-scripts test= Jest + RTL) butThere are no test files yet in src/. Build usesreact-scripts build (CRA Havetypecheck — else kiosk frontend ignores TS errors).
npm start # react-scripts start → http://localhost:3004 (PORT) npm run build # react-scripts build (with typecheck) npm test # react-scripts test — currently no test cases npm run eject # ⚠️ should not be used
There are no folderstests/ hay file *.test.tsx. When adding new features, consider writing tests for pure helpers (egshopWalletLedger, phone) because they are easy to unit test and carry a lot of data normalization logic.
Cookbook for new devs
Common tasks follow project conventions.
- Add async function in
src/services/api.tsuseclient.get/post/patch(start path/shop/..., DO NOT add/api). - Declare interface response in
src/types/index.ts(subject to camelCase/snake_case if needed). - In page/component, self-managed
loading/errorequaluseState+useCallback; error reporttoast.
- Create
src/pages/MyPage.tsx, wrap the content in<AppLayout>. - Declare
<Route>inApp.tsx, wrap<ProtectedRoute>if auth is needed. - If you want to go to the sidebar, add an item
NAV_ITEMSinAppLayout.tsx(putprimary: trueto show in bottom nav mobile).
- After successful deposit/transfer, call
dispatchShopCreditBalanceRefresh()(fromconstants/). AppLayoutis listening for this event → callback itselffetchDashboard()for sidebar balance card.- In the current page, call your local reload function (silent mode if you don't want a spinner).
- All lookup/OTP logic resides within
ShopPlayerLinkByPhonePanel.tsx— edit here, don't clone. - Remember to handle all 4
lookupStatusand bothotpFlowMode. - Modal in
PlayersPageusekeygradually increase to remount panel (clear state) — keep this pattern when reopening the modal.
Common traps (read before fixing)
| Trap | Details & how to handle |
|---|---|
Double /api | API_BASE_URLhad/api. The new function is write-only/shop/..., don't add/apiagain. |
| refreshToken is dropped | Client only savesaccessToken. 401 = log out, no refresh. If you need a long session, you have to add the logic yourself. |
| Two different balance thresholds | Sidebar hardcode 100 credits; Settings is USD threshold (min $50). Don't confuse the two numbers. |
| register-initiate returns transfer_required | In flow create can still be encounteredother_shop— handles both body 2xx and 4xx errors (available in the panel). |
| Ledger 2 casing | Field has both camelCase & snake_case, sometimes located inmetadata. Always use helpergetPresentation/normalizeMeta. |
| String mixed with Vietnamese | ShopCreditPurchaseModalthere is a string "Loading payment method..." in the middle of the English UI. Note language consistency when editing. |
| Filter payment tickers | Only BTC & USDT* appears (filterShopVisiblePurchaseOptions). Other methods are hidden even though the API returns them. |
| SHOP_FRONTEND_URL / SHOP_KIOSK_BASE_DOMAIN | Two server-side envs: missing header → wrong redirect payment; Missing the latter → cannot create kiosk domain. |
| Validate phone is only "enough to use" | isPlausibleInternationalPhonejust check+and 7–15 digits. The new server is the real source of authentication. |
| No testing yet | npm testIt works but there are no cases. Pure helpers are where you should start writing tests. |