Agent · Deep dive

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 economicscostRateof agent vs shop determines profit (commission). Read the following overview report04-agent.html.

React 18.2 CRA + Craco 7 TypeScript 4.9 Tailwind v3 React Router v6 Axios

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.

kioskgaming_agent/ ├── craco.config.js # webpack alias @kioskgaming/ui ├── ../packages/ui · ../packages/page-loading # workspace packages ├── kioskgaming_backend/ # reference clone — DO NOT use runtime └── src/ ├── App.tsx # Router + ProtectedRoute ├── pages/ │ ├── DashboardPage.tsx │ ├── ShopsPage.tsx · ShopDetailPageRedesign.tsx # ★ route /shops/:id │ ├── AgentTransactionsPage.tsx · OnlineBalanceTransactionsPage.tsx │ ├── cashTransactions/CashTransactionsLedgerPage.tsx │ ├── ProviderFeesPage.tsx · FraudIndicatorsPage.tsx │ └── SendBroadcastPage.tsx · SettingsPage.tsx ├── components/credits/ # ★ money operations │ ├── CreditPurchaseModal.tsx │ ├── SellCreditsCashToShopTrigger.tsx # POST cash-credits │ └── WithdrawCreditsFromShopTrigger.tsx ├── services/ api.ts # ~50 functions → /agent/* ├── types/ index.ts └── hooks/ useAuth.tsx · useAgentOnlineBalanceLedger.ts
💡
Read in what order?

Newbies should open:App.tsxservices/api.tstypes/index.tsCreditPurchaseModal.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/.

URLComponentProtectNote
/loginLoginPagepublicusername/email + password (+Turnstile, OTP 2FA)
/forgot-password/*3 trang resetpublicOTP email/phone → set new password
/DashboardPageprotectedCredit + USD balance sidebar
/transactionsAgentTransactionsPageprotectedLedger credit + export CSV
/online-transactionsOnlineBalanceTransactionsPageprotectedLedger USD + crypto withdrawal
/cash-transactionsCashTransactionsLedgerPageprotectedShop cash transactions
/provider-feesProviderFeesPageprotectedSee provider fees
/fraud-indicatorsFraudIndicatorsPageprotectedCheating players
/shops/:shopIdShopDetailPageRedesignprotectedSell/withdraw credits, game mapping
/purchaseNavigate to="/" — mua via modal
/popups/sendSendShopPopupPageprotectedPopup in-app
/broadcasts/sendSendBroadcastPageprotectedBroadcast
/settingsSettingsPageprotected2FA, verify, low balance
/payment/{success,cancelled,error}AgentPayment*PageprotectedRedirect provider
src/components/auth/ProtectedRoute.tsxtsx
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}</>;
🧭
SPA needs fallback rewrite

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.

1
Submit form

LoginPagecallloginWithUsernameOrEmail()POST /agent-auth/login. Can pay OTP challenge (verifyPortalLoginOtp).

2
Clean profiles

toAgentProfile(res.data.agent) (sanitizeUser.ts) just keepid/email/name/status/costRate- cancelpasswordHashand sensitive fields before saving.

3
Save & broadcast event

login(profile, accessToken) ghi agent_auth_token + agent_user_datago to localStorage, dispatchagent-auth-token-changedto popup gate refetch.

Go to the portal

navigate(from)return to the page the user intended before being blocked (default/).

🐛
2 points to know about auth

(1) No refresh token executes.Backend pays bothrefreshTokenbut the client just savesaccessToken. Any feedback401which (interceptor) will transmitauth:unauthorizedAuthProvidercalllogout()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.

PATTERNAxios singleton + interceptor
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api'included /apiso the paths are/agent/...(not repeated/api)
  • Timeout:15000ms; default headersContent-Type: application/json
  • Token:request interceptor readslocalStorage['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? }
src/services/api.ts (shortened)ts
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)

JawMethod · PathRole
loginWithUsernameOrEmailPOST /agent-auth/loginLogin (+ OTP 2FA)
verifyPortalLoginOtpPOST /agent-auth/verify-otpComplete 2FA
requestForgotPasswordOtpPOST /agent-auth/forgot-password/*Reset password
fetchDashboardGET /agent/dashboardcredit_balance + online_balance_net
convertBalanceToCreditsPOST /agent/convert-balance-to-creditsUSD → credit
fetchAgentOnlineBalanceLedgerGET /agent/online-balance/ledgerLedger USD
createAgentOnlineBalanceWithdrawalPOST /agent/online-balance/withdrawalsWithdraw crypto (OTP + challenge token)
createShopCashCreditsPOST /agent/shops/:id/cash-creditsSell ​​credits to shops (cash)
withdrawCreditsFromShopPOST /agent/shops/:id/credits/withdrawWithdraw credits from the shop
getAgentCashTransactionsGET /agent/cash-transactionsCash book
purchaseCreditsPOST /agent/credits/purchaseBuy credit online
listZeroxStaticWalletsGET /agent/payments/zeroxprocessing/static-walletsStatic wallet 0x
fetchProviderFeeConfigsGET /agent/provider-feesProvider fee
fetchAgentPlayerFraudIndicatorsGET /agent/player-fraud-indicatorsCheat
createShopPOST /agent/shopsCreate a shop (cost_rate)
suspendShop / unsuspendShopPATCH /agent/shops/:id/(un)suspendShop administration
updateShopCostPATCH /agent/shops/:id/costChange cost_rate
fetchAgentTransactionsGET /agent/transactionsLedger credit + export
fetchAgentBroadcasts / ackGETPOST /agent/broadcasts[/:id/ack]Notification bell
fetchAgentInAppPopupsUnseenGET /agent/in-app-popups/unseenPopup blocks the screen
📍
A public endpoint outside of api.ts

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.

src/types/index.ts (excerpt)ts
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.

Platform — mua @ agentCostRate → Agent Wallet (credit) — giao @ shopCostRate → Wallet Shop
Formula (from CreditPurchaseModal & DistributeCreditsTrigger)txt
# 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
💡
Immutable constraint: shop cost > agent cost

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

src/utils/costFormat.tsts
// 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;
}
⚠️
Unit trap

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.

1
Enter the credit number

Positive integer index (replace(/\D/g,'')). estimatedCharge = credits × costRateInstant display.

2
Select method

2 tab: Recommend(0x methods from API, filters only BTC + USDT throughisAgentPurchaseVisibleTicker) andOther(BTCPay, LinkMePay PYUSD, Cash App…). Pagination 8/page, with search box.

3
Validate by method

validateChargeUsd(): 0x needs $5–$10,000; Cash App only accepts denominationsECASH_APP_ALLOWED_AMOUNTS; Minimum remaining $10.

4
Create order & pay

purchaseCredits(credits, paymentMethod)→ receivepaymentUrl/paymentAddress. Modal embed iframecheckout page (sandbox) + "Open in new tab" button.

Validation provider → webhook

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

src/components/credits/creditPurchasePaymentMethods.tsxts
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;
}
🔮
ZEROX_PAYMENT_OPTIONS is just for "coloring"

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.

createShopCashCredits (api.ts)ts
// Payload: credits, payment_method?, received_at?, note?
await createShopCashCredits(shopId, { credits: 100, payment_method: 'cash' });
// → record cash-transaction + subtract agent credit + add shop credit
📥
Withdraw credits

WithdrawCreditsFromShopTriggerwithdrawCreditsFromShopRefund 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.

active ⇄ suspend/unsuspend suspended — terminate → terminated
StatusAPI functionAgent can do it
activesuspendShop, terminateShopDistribute credits, change cost/password, suspend, terminate
suspendedunsuspendShop, terminateShopReopen or terminate; blocked from logging in
terminatedJust 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.

⚖️
Create a shop = must set valid costs

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).

📌
Filter by shop

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).

🔒
Scope of rights

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.

🔔 AgentNotificationBell
  • Source:/agent/broadcasts, poll every 2 minutes
  • Unread Count Badge; click →ackAgentBroadcast(id)
  • Do not block operations; dropdown in header
🚪 AgentInAppPopupGate
  • 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 eventagent-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.

✍️
Compose & send

SendBroadcastPagecreateAgentBroadcast(transfertargetShopIds/targetUserIds sang snake_case target_shop_ids/target_user_ids). SendShopPopupPagecreateAgentInAppPopupwithdisplay_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.

🔌
CORS & internal package

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.

HOW-TOAdd a new backend endpoint
  1. Add export function insrc/services/api.tsuseclient.get/post/patch/put(...)(self-attached token).
  2. Declare the request/response type insrc/types/index.ts(remember envelope{ success, data?, message? }).
  3. Call from page/component withuseState/useEffect; error reporttoast (react-hot-toast).
  4. Path starts with/agent/...Are notmore/api(already in baseURL).
HOW-TOAdd a new route
  1. Create internal pagesrc/pages/, wrap the content in<AppLayout><AgentPageLayout>.
  2. More<Route> in App.tsx, wrap<ProtectedRoute>If you need to log in.
  3. Want to appear in sidebar/bottom-nav: addNAV_ITEMS in AppLayout.tsx(putprimary: trueto show in bottom-nav mobile).
HOW-TOWork with cost rate
  1. Display:formatCostRate(shop.costRate)(always safe with string|number|null).
  2. Receive from form %:percentInputToRate(input)then send fieldcost_rate.
  3. When creating/changing cost shop: checkrate > agentCostRatebefore calling the API.
HOW-TOAdd a payment method to buy credits
  1. If coin 0x: addZEROX_PAYMENT_OPTIONS(enrichment only; must be API/payment/supported-methodsNew support now available).
  2. If it is another provider: addotherPurchasePaymentOptions+ internal id mappingmapUiPaymentIdToAgentPaymentMethod.
  3. Add validation rules (if needed).validateChargeUsd in CreditPurchaseModal.

Common traps (read before fixing)

TrapDetails & how to handle
Confused rate vs percentagecostRateis decimal (0.05). Form uses %. Always passpercentInputToRate/formatCostRate.
Forget/apialready in baseURLPath in api.tsjust/agent/.... Don't write/api/agent/...(wall/api/api/...).
There is no refresh tokenEvery 401 →auth:unauthorized→ logout now. Token expires = log out.refreshTokenReturned but not used.
ShopDetailPage.tsxis dead codeRoute /shops/:iduseShopDetailPageRedesign(import alias toShopDetailPage). Editing the original will not work.
DistributeCreditsTriggerabandonedCredit distribution last hourcreateShopCashCredits(cash sale), no longer available/agent/credits/distribute in frontend.
PurchaseCheckoutPageabandonedBuy credits only throughCreditPurchaseModal; /purchaseredirect to/.
Two credit wallets + USDDashboard yescredit_balanceandonline_balance_net. Withdrawing USD requires OTP +withdrawChallengeToken.
ECASH blocks accordinglycharge USDECASH_APP_ALLOWED_AMOUNTSvsestimatedCharge(= credit × cost), not the number of credits.
costRate can be stringAgentShop.costRatetypestring | number→ alwaysNumber(...)before multiplying.
created_at vs createdAtLedger entry has both; player uses snake. Reading the field must fallback both variants.
2 notification systems are easy to confuseBell (/agent/broadcasts, poll 2′) ≠ blocking popup (/agent/in-app-popups). Endpoints & different components.
Asynchronous plus balanceAfter 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 tableMorelocalhost:3003Go to Admin → Domains (type admin) orDISABLE_CORS=true when dev local.