Technical details —Agent Portal
Dissecting the actual code of the B2B Agent portal: floorapi.ts(Axios), the type system around credit wallets, and most importantlycredit economics — costRateof agent vs shop determines profit (commission). Read the following overview report04-agent.html.
Stack & holistic thinking
This app is completely different from the kiosk frontend (Next.js): this is oneB2B management SPAruns on Create React App. Know these six characteristics before reading code — they explain most of the structure.
CRA + Craco, not Next.js
Scripts via craco start/build/test; craco.config.jscallpackages/ui/scripts/craco-configure-ui.cjsto bundle@kioskgaming/ui. Entry: src/index.tsx → <App/>.
Axios, no pure fetch
OneAxiosInstance singleton in services/api.tswith the interceptor attaching the Bearer token itself and emitting eventsauth:unauthorizedwhen encountering 401.baseURLincluded/api.
App revolves around credit wallet
Core business: agentmuacredits from the platform alreadydistributiondown to the shop. Difference betweencostRateof agent and shop is profit. This is the most worth reading section.
Only 1 Context: Auth
No Redux/Zustand/React Query. The only global state isAuthProvider (hooks/useAuth.tsx). Each page fetches its own data usinguseState/useEffect.
There is real route protection
Different from kiosk frontend, this appHave ProtectedRouteCover every page. Not logged in → redirect/login(keepstate.fromto return).
Use the workspace internal package
Import from@kioskgaming/ui(Modal, CommonModal, Button, PhoneInput) and@kioskgaming/page-loading— install itfile:../packages/*. Must build/symlink packages to run.
Directory structuresrc/
Clear layering:pages/(destination route) →components/ (by domain) → services/ + hooks/ + utils/. Two central files:services/api.tsandtypes/index.ts.
Newbies should open:App.tsx → services/api.ts → types/index.ts → CreditPurchaseModal.tsx & SellCreditsCashToShopTrigger.tsx(money operations).
Route map & route protection
App.tsxuseBrowserRouter + Routes. All business routes are wrapped internally<ProtectedRoute>; only/loginis public. Catch-all*and/purchaseevenNavigateabout/.
| URL | Component | Protect | Note |
|---|---|---|---|
/login | LoginPage | public | username/email + password (+Turnstile, OTP 2FA) |
/forgot-password/* | 3 trang reset | public | OTP email/phone → set new password |
/ | DashboardPage | protected | Credit + USD balance sidebar |
/transactions | AgentTransactionsPage | protected | Ledger credit + export CSV |
/online-transactions | OnlineBalanceTransactionsPage | protected | Ledger USD + crypto withdrawal |
/cash-transactions | CashTransactionsLedgerPage | protected | Shop cash transactions |
/provider-fees | ProviderFeesPage | protected | See provider fees |
/fraud-indicators | FraudIndicatorsPage | protected | Cheating players |
/shops/:shopId | ShopDetailPageRedesign | protected | Sell/withdraw credits, game mapping |
/purchase | — | — | Navigate to="/" — mua via modal |
/popups/send | SendShopPopupPage | protected | Popup in-app |
/broadcasts/send | SendBroadcastPage | protected | Broadcast |
/settings | SettingsPage | protected | 2FA, verify, low balance |
/payment/{success,cancelled,error} | AgentPayment*Page | protected | Redirect provider |
const { isAuthenticated, isLoading } = useAuth(); if (isLoading) return <PageLoadingIndicator variant="section" />; // wait to read localStorage if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
Because it is a BrowserRouter (no hash), deep reload (eg/shops/123) needs server rewriteindex.html. Repo already existspublic/_redirects, vercel.jsonandpublic/web.configto take care of this for Netlify/Vercel/IIS.
Login flow & JWT
1-step login (email + password, no need for email code like kiosk). Token and profile saved in localStorage;AuthProviderinitialize the state from there when opening the app.
LoginPagecallloginWithUsernameOrEmail() → POST /agent-auth/login. Can pay OTP challenge (verifyPortalLoginOtp).
toAgentProfile(res.data.agent) (sanitizeUser.ts) just keepid/email/name/status/costRate- cancelpasswordHashand sensitive fields before saving.
login(profile, accessToken) ghi agent_auth_token + agent_user_datago to localStorage, dispatchagent-auth-token-changedto popup gate refetch.
navigate(from)return to the page the user intended before being blocked (default/).
(1) No refresh token executes.Backend pays bothrefreshTokenbut the client just savesaccessToken. Any feedback401which (interceptor) will transmitauth:unauthorized → AuthProvidercalllogout()Now, push it back/login. Token expiration = log out, not auto-renew.(2)Don't use cookies/SameSite — the token resides entirely in localStorage.
services/api.ts— the brain communicates
OneAxiosInstanceUniquely, each endpoint is an export function. Request interceptor attaches token itself; response interceptor catches 401.Are notThere is an intermediate service layer like kiosk — page/component that calls these functions directly.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'— included/apiso the paths are/agent/...(not repeated/api) - Timeout:15000ms; default headers
Content-Type: application/json - Token:request interceptor reads
localStorage['agent_auth_token']→Authorization: Bearer <token> - 401: response interceptor
window.dispatchEvent(new Event('auth:unauthorized'))then reject - Envelope:All data returned as
{ success, data?, message? }
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); });
Full endpoint table (prefix base/api)
| Jaw | Method · Path | Role |
|---|---|---|
loginWithUsernameOrEmail | POST /agent-auth/login | Login (+ OTP 2FA) |
verifyPortalLoginOtp | POST /agent-auth/verify-otp | Complete 2FA |
requestForgotPasswordOtp | POST /agent-auth/forgot-password/* | Reset password |
fetchDashboard | GET /agent/dashboard | credit_balance + online_balance_net |
convertBalanceToCredits | POST /agent/convert-balance-to-credits | USD → credit |
fetchAgentOnlineBalanceLedger | GET /agent/online-balance/ledger | Ledger USD |
createAgentOnlineBalanceWithdrawal | POST /agent/online-balance/withdrawals | Withdraw crypto (OTP + challenge token) |
createShopCashCredits | POST /agent/shops/:id/cash-credits | Sell credits to shops (cash) |
withdrawCreditsFromShop | POST /agent/shops/:id/credits/withdraw | Withdraw credits from the shop |
getAgentCashTransactions | GET /agent/cash-transactions | Cash book |
purchaseCredits | POST /agent/credits/purchase | Buy credit online |
listZeroxStaticWallets | GET /agent/payments/zeroxprocessing/static-wallets | Static wallet 0x |
fetchProviderFeeConfigs | GET /agent/provider-fees | Provider fee |
fetchAgentPlayerFraudIndicators | GET /agent/player-fraud-indicators | Cheat |
createShop | POST /agent/shops | Create a shop (cost_rate) |
suspendShop / unsuspendShop | PATCH /agent/shops/:id/(un)suspend | Shop administration |
updateShopCost | PATCH /agent/shops/:id/cost | Change cost_rate |
fetchAgentTransactions | GET /agent/transactions | Ledger credit + export |
fetchAgentBroadcasts / ack | GETPOST /agent/broadcasts[/:id/ack] | Notification bell |
fetchAgentInAppPopupsUnseen | GET /agent/in-app-popups/unseen | Popup blocks the screen |
hooks/useSupportedZeroxMethods.tscreate your own axios callGET /payment/supported-methods?provider=zeroxprocessing&operation=deposit(public, not Bearer) andcache 1 day in localStorage (zerox_supported_methods:deposit). Used to build a coin list in the credit purchase method.
types/index.ts— shape data domain
The most important type revolves around wallet and cost. Note that many fields have both camelCase and snake_case because the backend returns inconsistently — the code must guard against both.
interface AgentProfile { id: string; email: string; name: string; status: string; costRate?: number; // agent's purchase rate (0–1) } interface AgentDashboardData { credit_balance: number; active_shops_count: number; low_balance_warning_enabled?: boolean; /* ... popup permission flag ... */ } interface AgentWalletLedgerEntry { // 1 line /agent/transactions id: string; type: string; amount: number | null; direction: 'in' | 'out' | 'unknown'; counterparty: { ownerType: string; ownerId: string } | null; costRate?: number; costAmount?: number; // commission written in each line platformFeeRate?: number; platformFeeAmount?: number; createdAt?: string; created_at?: string; // ⚠ both types } interface AgentShop { id: string; name: string; status: AgentShopStatus; costRate?: string | number; // ⚠ string OR number minShopCostRateExclusive?: number; // cost shop must be > this level }
costRateis a decimal number
0.05 = 5%. AgentShop.costRatecould bestring or number→ alwaysNumber(...)before calculating. UI shows throughformatCostRate().
AgentCreditPurchaseData
Response bypurchaseCredits: paymentUrl, paymentAddress, invoiceId, transactionId, amount(money payable to provider),creditAmount(credit received),status.
snake vs camel
AgentPlayeruse snake (shop_id, created_at), AgentWalletLedgerEntryUse camel + fallback snake. Read the right field to prevent both variations.
AgentShopHierarchyStatsData
Haverange, summary(total deposits/withdrawal/users) and arrayshops[]Statistics for each shop — feeds data to the Dashboard hierarchical tree.
costRate & commission — Agent's commission model
This is the most important concept of repo. Agents make money thankscost rate difference: buy cheap credits (according toagentCostRate), delivered to the shop at a more expensive price (according toshopCostRate). The difference is the commission.
# When AGENT BUY credits from the platform: estimatedCharge (USD) = credits × agentCostRate # e.g. 100 credit × 0.30 = pay 30 USD to provider # Khi AGENT GIAO credit for shop: cashShopOwesAgent (USD) = credits × shopCostRate # e.g. 100 credit × 0.50 = shop owes agent 50 USD # ⇒ agent PROFIT / 100 credits: margin = credits × (shopCostRate − agentCostRate) # = 100 × (0.50 − 0.30) = 20 USD
Trong ShopsPage.onCreate, cost shop rightbigger than strict agent cost: if (rate <= minShopCostExclusive + 1e-6) reject. Datum value taken fromfetchShops().agent_cost_rate (fallback user.costRate). If not satisfied, the agent willholeper credit delivered.
utils/costFormat.ts— conversion % ⇄ rate
// rate (0.05) → display "5%" (Intl.NumberFormat style:'percent') export function formatCostRate(raw: string|number|null|undefined): string // input "5" (user types %) → 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; }
API and type useddecimal rate(0.05). UI form usedpercent(5). Always passing bypercentInputToRate / formatCostRateto transfer; Don't send "5" directlycost_rate(will become 500%).
CreditPurchaseModal — buy credits from the platform
Full screen modal (components/credits/CreditPurchaseModal.tsx). User enters numbercredits want to receive, choose the method, the system estimates the amount to be paid, then creates an order and embeds the provider payment page.
Positive integer index (replace(/\D/g,'')). estimatedCharge = credits × costRateInstant display.
2 tab: Recommend(0x methods from API, filters only BTC + USDT throughisAgentPurchaseVisibleTicker) andOther(BTCPay, LinkMePay PYUSD, Cash App…). Pagination 8/page, with search box.
validateChargeUsd(): 0x needs $5–$10,000; Cash App only accepts denominationsECASH_APP_ALLOWED_AMOUNTS; Minimum remaining $10.
purchaseCredits(credits, paymentMethod)→ receivepaymentUrl/paymentAddress. Modal embed iframecheckout page (sandbox) + "Open in new tab" button.
Credit balance plusasynchronousin the backend via the IPN webhook, not immediately upon closing the modal. Provider redirects back/payment/success|cancelled|error.
Mapping UI → API methods
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; }
zeroxPaymentOptions.tsis a static list of ~20 coins (name, ticker, network, flagcomingSoon). ListRealcomes from the API/payment/supported-methods; The static version only enriches name/network/icon. Many coins attachedcomingSoon: trueshould be disabled.
SellCreditsCashToShopTrigger — cash-credits flow
Instead of the old "free" credit distribution, agents sell credits viaPOST /agent/shops/:id/cash-credits. UI usedSellCreditsCashModalfrom@kioskgaming/ui.
// Payload: credits, payment_method?, received_at?, note? await createShopCashCredits(shopId, { credits: 100, payment_method: 'cash' }); // → record cash-transaction + subtract agent credit + add shop credit
WithdrawCreditsFromShopTrigger → withdrawCreditsFromShopRefund credit to agent wallet when needed.
Lifecycle & shop's governance
ShopsPage(list + create) andShopDetailPageRedesign(details) is where the agent exercises full CRUD authority on his shop, including setting prices (cost), resetting passwords, turning on games and managing status.
| Status | API function | Agent can do it |
|---|---|---|
| active | suspendShop, terminateShop | Distribute credits, change cost/password, suspend, terminate |
| suspended | unsuspendShop, terminateShop | Reopen or terminate; blocked from logging in |
| terminated | — | Just watch;do not recover, wallet closed, player unassigned |
ShopGovernanceModal— confirm dangerous action
UseModalfrom@kioskgaming/uiwithvariant='delete'for terminate ("irreversible" red warning) andvariant='confirm'for suspend (yellow warning). CallbackonConfirmSuspend/TerminatepayPromise<boolean>; Only close the modal when successful.
createShopsend{ name, email, phone?, password, cost_rate }. Form usedreact-hook-form + PhoneInput. Before sending,cost_ratebeen counted throughpercentInputToRateand yes> agent cost(see Credit Economics section).
Wallet History &agentWalletLedger.ts
AgentTransactionsPageUse hooksuseAgentTransactions({page, limit})(default limit 25) call/agent/transactions. Because the shape ledger is complex, all display logic is combinedutils/agentWalletLedger.ts.
signedChange(tx)
Calculate positive/negative numbers for the wallet being viewed: prioritydirection ('in'/'out') + amount; fallback balanceAfter − balanceBefore. Necessary because with external credit (fromWallet=null) balance is 0→0.
walletTypeLabel(type)
Map deposit/withdrawal/transfer/bonus/adjustment/reservation…beautiful labels; strange typereplace(/_/g,' ').
referenceLabel / distributionTarget
TakereferenceId/transactionIdto show; Identify the partner as shop whencounterparty.ownerType === 'shop'(distribution transaction).
formatSignedAmount(n)
Add accents+/-and formattoLocaleString(2–8 decimal places).
fetchAgentTransactionsreceiveshop_idto only pay for lines that have that shop as a partner (shop must belong to the agent). The default hook does not pass this parameter — the UI that wants to filter must add it itself.
Players — read only
Agent Are notcreate/edit/delete player.PlayersPage (/agent/players) lists all players from all shops;AgentPlayerDetailPage + AgentPlayerDetailPanelshow profile + balance + wallet history (/agent/players/:id/transactions).
AgentPlayeruse snake_case (shop_id, shop_assigned_at, created_at). AgentPlayerDetailexpand furtherbalance, last_login_at, shop_name. All are visibility-only — there are no mutation player endpoints inapi.ts.
Two separate notification systems
Easy to mistake: the app does2 different mechanismswith different endpoints. One is a notification bell (not blocked), the other is a popup that blocks the screen.
- Source:
/agent/broadcasts, poll every 2 minutes - Unread Count Badge; click →
ackAgentBroadcast(id) - Do not block operations; dropdown in header
- Source:
/agent/in-app-popups/unseen - Block full screen via
createPortal, there are queues (n of N) once→ mark seen;sticky_daily→ OK / "Don't show again"- Refetch upon event
agent-auth-token-changed
Markup link in broadcast
utils/broadcastLinkMarkup.tsparse syntax<link>label|https://...</link>into a secure text/link segment (only accepts http/https viasanitizeBroadcastUrl). BroadcastMessageBody.tsxrender result — common to both bell and popup gate.
SendBroadcastPage → createAgentBroadcast(transfertargetShopIds/targetUserIds sang snake_case target_shop_ids/target_user_ids). SendShopPopupPage → createAgentInAppPopupwithdisplay_modedefault'once'.
Styling, config & build
Tailwind v3 Haveconfig file (different from kiosk v4), incorporating the HSL token design of the shared UI package. Config app wrapped inconfig/env.ts + constants/index.ts.
Tailwind v3 + token UI
tailwind.config.jssweep./src/**and../packages/ui/dist/**; color expansion from HSL variable (--primary, --muted, --destructive…). Main style: orange/amber (orange-600) for agent action.
Env (CRA prefix)
Every variable must have a prefixREACT_APP_: REACT_APP_API_BASE_URL, REACT_APP_TURNSTILE_ENABLED/SITE_KEY, REACT_APP_APP_NAME. Available.env.examplewithPORT=3003.
Build & deploy
craco start/build (PORT 3003). SPA fallback: _redirects, vercel.json, web.config.
Testing
CRA default test configuration (react-scripts test, jest + RTL via react-app/jest) but repoThere is no real test file yet. TS turned onstrict, target es5.
Backend uses tablesdomains (type admin) as CORS allowlist — must be addedlocalhost:3003in Admin Domains or setDISABLE_CORS=truewhen dev. Besides@kioskgaming/ui & @kioskgaming/page-loadinginstall itfile:../packages/*; if missing../packagesthennpm installwill fail.
Cookbook for new devs
Common tasks follow repo conventions.
- Add export function in
src/services/api.tsuseclient.get/post/patch/put(...)(self-attached token). - Declare the request/response type in
src/types/index.ts(remember envelope{ success, data?, message? }). - Call from page/component with
useState/useEffect; error reporttoast(react-hot-toast). - Path starts with
/agent/...— Are notmore/api(already in baseURL).
- Create internal page
src/pages/, wrap the content in<AppLayout><AgentPageLayout>. - More
<Route>inApp.tsx, wrap<ProtectedRoute>If you need to log in. - Want to appear in sidebar/bottom-nav: add
NAV_ITEMSinAppLayout.tsx(putprimary: trueto show in bottom-nav mobile).
- Display:
formatCostRate(shop.costRate)(always safe with string|number|null). - Receive from form %:
percentInputToRate(input)then send fieldcost_rate. - When creating/changing cost shop: check
rate > agentCostRatebefore calling the API.
- If coin 0x: add
ZEROX_PAYMENT_OPTIONS(enrichment only; must be API/payment/supported-methodsNew support now available). - If it is another provider: add
otherPurchasePaymentOptions+ internal id mappingmapUiPaymentIdToAgentPaymentMethod. - Add validation rules (if needed).
validateChargeUsdinCreditPurchaseModal.
Common traps (read before fixing)
| Trap | Details & how to handle |
|---|---|
| Confused rate vs percentage | costRateis decimal (0.05). Form uses %. Always passpercentInputToRate/formatCostRate. |
Forget/apialready in baseURL | Path in api.tsjust/agent/.... Don't write/api/agent/...(wall/api/api/...). |
| There is no refresh token | Every 401 →auth:unauthorized→ logout now. Token expires = log out.refreshTokenReturned but not used. |
ShopDetailPage.tsxis dead code | Route /shops/:iduseShopDetailPageRedesign(import alias toShopDetailPage). Editing the original will not work. |
DistributeCreditsTriggerabandoned | Credit distribution last hourcreateShopCashCredits(cash sale), no longer available/agent/credits/distribute in frontend. |
PurchaseCheckoutPageabandoned | Buy credits only throughCreditPurchaseModal; /purchaseredirect to/. |
| Two credit wallets + USD | Dashboard yescredit_balanceandonline_balance_net. Withdrawing USD requires OTP +withdrawChallengeToken. |
| ECASH blocks accordinglycharge USD | ECASH_APP_ALLOWED_AMOUNTSvsestimatedCharge(= credit × cost), not the number of credits. |
| costRate can be string | AgentShop.costRatetypestring | number→ alwaysNumber(...)before multiplying. |
| created_at vs createdAt | Ledger entry has both; player uses snake. Reading the field must fallback both variants. |
| 2 notification systems are easy to confuse | Bell (/agent/broadcasts, poll 2′) ≠ blocking popup (/agent/in-app-popups). Endpoints & different components. |
| Asynchronous plus balance | After purchasing credits, the balance updates via the IPN webhook on the backend — not when closing the modal. The return page is just a notification. |
| Need internal package | @kioskgaming/ui & @kioskgaming/page-loading via file:../packages/*. Missing packages folder → install/build failed. |
| CORS according to domains table | Morelocalhost:3003Go to Admin → Domains (type admin) orDISABLE_CORS=true when dev local. |