Technical details —Super Agent Portal
Delve into the execution mechanism of the Super Agent portal: the essence is onefork of Agent Portal(CRA React 18), adding an Agent administration layer, distributing credits to agents, game mapping per-agent, 2-level hierarchy tree and Service Worker (PWA). Read the following overview report05-superagent.html.
Stack & holistic thinking
Before reading the code, grasp the 6 "strange" features of this app. Much of it comes from one truth:This repo is a clone of Agent Portal, was expanded rather than rewritten from scratch — many of the names in the code are still "Agent".
App is a fork of Agent Portal
package.jsonstill recorded"name": "kioskgaming-agent", README.mdwrite "Agent Portal", and all type/hooks use the prefixAgent* (AgentProfile, useAgentTransactions…). Super Agent = Agent + agent management layer.
Useaxios, not fetch
Different from Kiosk Frontend. A singletonclient in services/api.tswith interceptor attaching Bearer token and catching 401.timeout: 15000.
Real routing using React Router v6
Not a state machine like Kiosk. HaveBrowserRouter, <Routes>, deep-link, ProtectedRoutewrap every route except/login.
401 = logout, NO refresh
Login paid for everythingrefreshTokenbut appDon't save or use it. Encounter 401 → fire eventauth:unauthorized → AuthProviderdelete localStorage. Expires = re-login.
PWA: the only repo with Service Workers
service-worker.js + Workbox. Register only in production (NODE_ENV==='production'). Supports offline, precache, image cache.
Tailwind v3 + theme cam
Havetailwind.config.js(HSL CSS vars). The actual UI uses tonescam (orange-500/600), not yellow — yellow is just the theme of this document.
Newbies should open:src/App.tsx (routes) → src/services/api.ts(all endpoints) →src/hooks/useAuth.tsx (auth) → src/pages/SuperAgentManagementPage.tsxandManagedAgentDetailPage.tsx(2 SA exclusive pages). These 5 files are ~75% "different from Agent".
Directory structuresrc/
Standard CRA tree:pages/ for route component, components/ for UI con, services/api.tscentralize all HTTP. ★ files are exclusive to Super Agent.
Don't be mistaken:AgentProfile, AgentDashboardData, AgentTransactionsPage, fetchAgentTransactions… all serveSuper Agent. Type SuperManagedAgent/Shop/Playernew is "agent/shop/player that SA manages".
Startup Screen & Register Service Worker
What happens when the browser opens the app. Different from Kiosk Frontend, this is a pure CRA SPA client.
Mount React 18 createRoot; then callserviceWorkerRegistration.register()at the end of the file.
<AuthProvider> → <BrowserRouter> → <Routes> + <Toaster/> (react-hot-toast).
Readagent_auth_token & agent_user_datafrom localStorage; if valid →isAuthenticated=true. While reading,isLoading=true.
/ redirect /dashboard. Not logged in →ProtectedRoutepush back/login(holdstate.fromto go back).
const root = ReactDOM.createRoot(document.getElementById('root')!); root.render(<React.StrictMode><App /></React.StrictMode>); // SW only actually registers when NODE_ENV === 'production' serviceWorkerRegistration.register();
Kiosk is a 1-session touch screen, no URL needed. Super Agent is a multi-site administration tool, needs deep-links, back/forward, bookmarks — should use a real router. Each role chooses the appropriate model.
Hierarchy SuperAgent → Agent → Shop → Player
This is the central concept. Super Agent standstopB2B chain: buy credits from platform, create & Manage many Agents, each Agent manages many Shops, each Shop has many Players.
What SA can do (via /super-agent/*)
- Create / Suspend / Unsuspend / Terminate Agent
- Fix
cost_rateby Agent - Grant credits to Agent (debit SA wallet)
- Configure game provider per-Agent
- Move Shop to another Agent, Move Player to another Shop
- See all agents/shops/players + statistics
"Similar" to the pair Agent→Shop
The relationship SA→Agent is a copy of the relationship Agent→Shop one level up: same formulacash = credits × cost_rate, same active/suspended/terminated lifecycle, same "parent wallet loaded to child wallet" model.
| Grant | Type in code | Data source |
|---|---|---|
| Super Agent (me) | AgentProfile | login + /super-agent/dashboard |
| Agent (con) | SuperManagedAgent | GET /super-agent/agents |
| Shop (nephew) | SuperManagedShop | GET /super-agent/shops(attachedagent) |
| Player (great-grandson) | SuperManagedPlayer | GET /super-agent/players(attachedshop_name, agent_name) |
The API does not return nested trees. SA fetches 3 flat lists and then automatically joins them at the client:shop.agent?.idconnect shop with agent,player.shop_id/player.agent_idConnect player to shop/agent.HierarchyMiniTreegroup together byuseMemo.
Routing & ProtectedRoute
React Router v6 in App.tsx. All routes (except/login) wrapped inProtectedRoute; Route mismatch redirects back/dashboard.
| Path | Component | Note |
|---|---|---|
/login | LoginPage | Public; has Turnstile captcha (depending on env) |
/ | — | Navigate → /dashboard |
/dashboard | DashboardPage | Stat cards + HierarchyMiniTree |
/agents | SuperAgentManagementPage section="agents" | SA onlylist + create agent |
/agents/:agentId | ManagedAgentDetailPage | SA onlydetails, 4 tabs |
/shops | SuperAgentManagementPage section="shops" | All shops + move |
/players | SuperAgentManagementPage section="players" | All players + move |
/agents/new | CreateManagedAgentPage | Create managed agent |
/online-transactions | OnlineBalanceTransactionsPage | Ledger USD + crypto withdrawal |
/cash-transactions | CashTransactionsLedgerPage | Cash transactions |
/payment/return | AgentPaymentReturnPage | Callback mua credit online |
/transactions | AgentTransactionsPage | History of SA wallet |
/payment/return | AgentPaymentReturnPage | The page returns to the payment gateway |
* | — | Navigate → /dashboard |
const { isAuthenticated, isLoading } = useAuth(); if (isLoading) return <PageLoadingIndicator variant="section" />; if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
ProtectedRoutejust check "logged in",Are notrole check. The fact that a route is "SA exclusive" is guaranteed bybackend via prefix /super-agent/*+ SA tokens. Frontend does not have role-based guards.
services/api.ts— API call center
A single file contains all backend call functions, exported for each operation. Component imports the function to use, automatically manages loading/error.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'(prefix/apiavailable in the base) - Request:read
agent_auth_tokenfrom localStorage → addAuthorization: Bearer <token> - Response 401:
window.dispatchEvent(new Event('auth:unauthorized'))then reject — DO NOT refresh - Timeout:
15000ms - Envelope:standard response
{ success, data?, message? }
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((r) => r, (error) => { if (error.response?.status === 401) window.dispatchEvent(new Event('auth:unauthorized')); return Promise.reject(error); });
/agent/*(Not/super-agent/*)Because it is a fork, legacy features still call the old prefix:fetchShops() → /agent/shops, fetchAgentPlayers() → /agent/players, broadcasts/in-app-popups → /agent/broadcasts, /agent/in-app-popups/*, low-balance → /agent/settings/low-balance-warning, issue credits to the shop →POST /agent/credits/distribute. The backend must accept SA tokens for both prefixes. When debugging 403/404, always check the actual prefix of the function.
Login flow & JWT
One-step login with email + password (with optional captcha). LocalStorage storage token. No OTP code like Kiosk, no refresh token.
loginWithEmailPassword(email, password, captchaToken?) → POST /super-agent-auth/login. IfREACT_APP_TURNSTILE_ENABLEDTo enable it, a Cloudflare Turnstile token is required.
Takeres.data.agent or res.data.superAgent(backend returns different keys depending on version), standardizedtoAgentProfile()(remove passwordHash).
login(profile, accessToken) ghi agent_auth_token + agent_user_datago to localStorage; shootagent-auth-token-changed. refreshToken is ignored.
navigate(from)— return to the previously blocked page (vialocation.state.from) or/dashboard.
Save & restore
localStorage: agent_auth_token, agent_user_data. Khi reload, AuthProviderread again; JSON error → selflogout().
Logout = pure client
logout()just delete localStorage + reset state,do not call the API. Also triggered automatically when receiving eventsauth:unauthorized (401).
(1)There is no refresh token → when the access token expires, the user will be kicked out to login immediately (all running requests fail 401).(2) STORAGE_KEYSusecommon namewith Agent Portal (agent_auth_token). If you run SA and Agent app on the same origin (same host:port) they willoverwrite each other's tokens. The app avoids this by using another port (default SA3003).
State management
Minimalist: only 1 Context (Auth). All business data is local state in each page, loaded byPromise.all when mount.
- AuthContext (
useAuth): user, token, isAuthenticated, isLoading - Local stateper page: agents/shops/players, loading, modal, search/filter
- react-hook-form for form login
- react-hot-toastfor notifications (instead of notificationService)
- useMemo for filter/group (
filteredAgents,shopsByAgent…)
- Redux / Zustand / Jotai
- React Query / SWR (every fetch is handwritten)
- Cross-page cache — each page fetches itself
- Optimistic update — always call again after mutate
load()
const [agentsRes, shopsRes, playersRes] = await Promise.all([ fetchManagedAgents(), // GET /super-agent/agents fetchManagedShops(), // GET /super-agent/shops fetchManagedPlayers() // GET /super-agent/players ]); setAgents(agentsRes.data ?? []); /* …setShops, setPlayers */
SuperAgentManagementPage — 3 section in 1 component
One component used for 3 routes (/agents, /shops, /players) via prop section. Note: these are 3 separate routes, not tabs— move section = change URL, component re-mount & fetch all 3 lists again.
| section | Main action | Search / Filter |
|---|---|---|
agents | Create agent (createManagedAgent), Suspend/Unsuspend, Terminate, link to details | Search name/email/phone → filteredAgents |
shops | Move shop to another agent | Search + filter by agent → filteredShops |
players | Move player to another shop | Search + filter by shop → filteredPlayers |
Create Agent — client-side validation
const PASSWORD_MIN = 8; const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/; // % input (0–100) → decimal rate (0–1), rounded to 4 digits const toRate = (percent: string) => Math.min(1, Number((Number(percent) / 100).toFixed(4))); createManagedAgent({ name, email, phone, password, cost_rate: toRate(form.costPercent), can_create_shop_popup, can_manage_shop_popup_permission, default_shop_can_create_player_popup });
Phone & popup permissions
Phone input usedreact-phone-number-input(US default). 3 popup/broadcast authorization checkboxes for agents:can_create_shop_popup, can_manage_shop_popup_permission, default_shop_can_create_player_popup.
Terminate requires typing confirmation
window.prompt('Type "terminate"…'); must type the correct letterterminatejust called the API. This is an anti-mistake mechanism, not a beautiful modal.
Move Shop / Move Player
PATCH /super-agent/shops/{id}/move body { target_agent_id }. Dropdown disableThe shop's current agent does not "move into itself".
PATCH /super-agent/players/{id}/move body { target_shop_id }. Dropdown appearsAll shops have the name of the owner agentto have context; disable the current shop.
The overview document says PUT/POST for suspend/terminate/move, but the code is usedPATCHfor suspend/unsuspend/terminate/rates/move. Separately create agent & grant credits for usePOST; game-mappings usedPUT. Always take the verb fromapi.ts.
ManagedAgentDetailPage— 4 tabs controlled by URL
Route /agents/:agentId. Tab saved in query?tab=(hold when F5). Different from the management page (using routes), this is a real tab in the same URL.
const [searchParams, setSearchParams] = useSearchParams(); const activeTab = isAgentDetailTab(searchParams.get('tab')) ? searchParams.get('tab') : 'info'; // info is default → remove param 'tab' from URL for brevity const setActiveTab = (t) => setSearchParams((p) => { const n = new URLSearchParams(p); t === 'info' ? n.delete('tab') : n.set('tab', t); return n; }, { replace: true });
Tab Info
Status badge, credit_balance, Credit button (SuperAgentAgentCreditTrigger), Suspend/Terminate, and fixcost rateinline. The hint "must be higher than SA's rate" displays the wordsuperAgentProfile.costRate.
Tab Games
Parallel downloadfetchManagedAgentGameMappings + fetchPublicGameProviders. Select All or Selected. See section 10.
Tab Shops
Filter clients:shops.filter(s => s.agent?.id === agentId). Show total/active + shop table.
Tab Players
Filter clients:players.filter(p => p.agent_id === agentId). Show total/active + player table.
load()callfetchManagedAgents/Shops/Players(all) already.find()/.filter() by agentIdin client. For large systems, this is where bandwidth is wasted; If optimization is needed, suggest additional backendGET /super-agent/agents/:id.
Configure Game Provider for each Agent
SA limits the set of games each agent is allowed to use. Inherited logic: the agent only sees games in the SA's "pool"; save empty array = "inherit all".
Promise.all([fetchManagedAgentGameMappings(agentId), fetchPublicGameProviders()]). Mapping paidgame_ids(of agent) +super_agent_game_ids (pool SA).
visibleGameProviders: IfparentGamePool.length > 0only shows games belonging to that pool; if pool is empty → show all providersstatus !== 'maintenance'.
game_idsempty → modeAll(inherit pool SA); has element → modeSelectedwith checkboxes.
putManagedAgentGameMappings(agentId, payload)withpayload = mode==='all' ? [] : selectedGameIds.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /super-agent/agents/{id}/game-mappings | Pay{ game_ids, super_agent_game_ids } |
| PUT | /super-agent/agents/{id}/game-mappings | Body { game_ids } — []= inherit SA |
| GET | /game-providers | Public category, response{ data: { items[] } } |
/game-providersNot/public/game-providersas an overview document. The data is insidedata.items[](each item:{ id, name, code, status? }), is filtered outstatus === 'maintenance'before rendering.
Credits Distribution
Two cash flows: (1) SAmuacredits from platform; (2) SAgrantcredits down Agent. Leveling down to Shop reuses the Agent Portal component.
SA → Agent: SuperAgentAgentCreditTrigger
Component is located in the Info tab of the agent details. Open modal → download SA balance (fetchDashboard) → enter amount → preview cash → submit.
// Preview: cash that the agent must pay to SA const effectiveCostRate = Math.min(1, Math.max(0, Number(agentCostRate ?? 0))); const cashToCollect = credits * effectiveCostRate; // eg: 100 credits × 0.95 = $95.00 (agent pays SA) await distributeCreditsToManagedAgent(agentId, amount); // POST /super-agent/agents/{id}/cash-credits — sell credits to agent (cash)
SA buys credits from the platform
purchaseCredits(amount, paymentMethod?) → POST /super-agent/credits/purchase. /createManagedAgentCashCredits — POST /super-agent/agents/:id/cash-creditssupports 0x/crypto (LinkMePay) payment methodpaymentUrlto redirect.
Grant credits to Shop (shared use)
distributeCredits(shopId, amount) → POST /agent/credits/distribute body { shop_id, amount }— this is the endpointby Agent, not super-agent.
/credits/distributecreateManagedAgentCashCreditscallPOST /super-agent/agents/{id}/cash-credits. WithdrawCreditsFromAgentTriggerrevoke credits. Recipe:cash = credits × agent_cost_rate.
HierarchyMiniTree— 2-level tree + statistics
Unlike Agent Portal (1 level Agent→Shop), SA sees 2 levels:SA → Agents → Shops, with statistics on deposits/withdrawal/users by date range.
type Props = { superAgentName: string; agents: SuperManagedAgent[]; shops: SuperManagedShop[] }; // Group shops by agent (Map<agentId, shops[]>), sort by name const shopsByAgent = useMemo(() => { /* ... */ }, [shops]); // Statistics by period (default 1 most recent month) fetchSuperAgentShopHierarchyStats({ from, to }); // GET /super-agent/shops/stats?from=&to=
Interact
Root SA expand/collapse; Each agent expands separately to view the shop as a card grid;StatusDoteach node (active/suspended/terminated); shop card link to/agents/{agentId}.
Statistical
Date range from/to (default 30 days). Summary row total deposits/withdrawal/new users/total users; Each shop has its own statsstats.shops[] map by id.
/super-agent/shops/statsNot/super-agent/shop-hierarchy-stats. Response: { range, summary, shops[] }; Every shop has itagent_id, agent_name, total_users, new_users, total_deposit, total_withdrawal.
Service Worker (Workbox)
The only repo in the ecosystem that has SW — suitable for the "regional manager" role needed on tablet/mobile.Only runs in production.
clientsClaim(); // SW just claimed right away, no waiting precacheAndRoute(self.__WB_MANIFEST); // precache asset build-time // SPA: navigate request → return index.html to React Router registerRoute(isNavigate, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')); // .png image from the same origin → StaleWhileRevalidate, maximum 100 entries registerRoute(isPng, new StaleWhileRevalidate({ cacheName: 'images', plugins: [new ExpirationPlugin({ maxEntries: 100 })] }));
Precache
__WB_MANIFESTinjected by Workbox at build time; Cache JS/CSS/static fonts.
SPA fallback
Navigate (not file, not start/_) → index.html.
SKIP_WAITING
Listen to messagesSKIP_WAITING → self.skipWaiting()for quick updates.
serviceWorkerRegistration.register()run only whenprocess.env.NODE_ENV === 'production'. Runnpm start(dev) willAre notRegister for SW — if you want to test PWA, you mustnpm run buildthen serve directorybuild/.
Config & environment variable
Stay focusedsrc/config/env.ts + src/constants/index.ts. Every variable must have a prefixREACT_APP_(CRA convention) can only be used on the client.
| Variable | Default | Meaning |
|---|---|---|
REACT_APP_API_BASE_URL | http://localhost:3001/api | Base backend URL (included/api) — shares backend with admin |
REACT_APP_APP_NAME | Kiosk Gaming — Agent | App name (legacy fork, still says "Agent") |
REACT_APP_TURNSTILE_ENABLED | false | Turn on Cloudflare Turnstile at the login screen |
REACT_APP_TURNSTILE_SITE_KEY | — | Site key Turnstile |
PORT | 3003 | Avoid touching admin (3000) — important to avoid clashing localStorage tokens |
.env.exampleCopy .env.example → .env. Backend needs to be setAGENT_FRONTEND_URLpoint to this app (eghttp://localhost:3003) to redirect the payment back to the correct one/payment/return. CORS: add hostname to the tabledomains(type admin) orDISABLE_CORS=true when dev.
Summary API Endpoints (according to real code)
Excerpted fromservices/api.ts. Note the double prefix:/super-agent/*(SA feature) mixed/agent/*(inherited from fork).
| Method | Endpoint | Jaw | Purpose |
|---|---|---|---|
| POST | /super-agent-auth/login | loginWithEmailPassword | Login SA |
| GET | /super-agent/dashboard | fetchDashboard | Balance + shop/agent count |
| GET | /super-agent/agents | fetchManagedAgents | List of agents |
| POST | /super-agent/agents | createManagedAgent | Create agents |
| PATCH | /super-agent/agents/{id}/suspend | suspendManagedAgent | Suspend |
| PATCH | /super-agent/agents/{id}/unsuspend | unsuspendManagedAgent | Remove suspension |
| PATCH | /super-agent/agents/{id}/terminate | terminateManagedAgent | Termination ({confirm:true}) |
| PATCH | /super-agent/agents/{id}/rates | updateManagedAgentRate | Edit cost_rate (plural:rates) |
| POST | /super-agent/agents/{id}/cash-credits | createManagedAgentCashCredits | Sell SA credits→Agent (cash) |
| POST | /super-agent/agents/{id}/credits/withdraw | withdrawCreditsFromManagedAgent | Recover credits |
| GET | /super-agent/agents/{id}/game-mappings | fetchManagedAgentGameMappings | Get the agent's game |
| PUT | /super-agent/agents/{id}/game-mappings | putManagedAgentGameMappings | Save game ({game_ids}) |
| GET | /super-agent/shops | fetchManagedShops | All shops |
| PATCH | /super-agent/shops/{id}/move | moveManagedShop | Move shop ({target_agent_id}) |
| GET | /super-agent/shops/stats | fetchSuperAgentShopHierarchyStats | Statistical hierarchy |
| GET | /super-agent/players | fetchManagedPlayers | All players |
| PATCH | /super-agent/players/{id}/move | moveManagedPlayer | Move player ({target_shop_id}) |
| POST | /super-agent/credits/purchase | purchaseCredits | Buy credits from the platform |
| GET | /super-agent/transactions | fetchAgentTransactions | History of SA wallet |
| GET | /game-providers | fetchPublicGameProviders | Public game directory |
| POST | /agent/credits/distribute /agent/* | distributeCredits | Grant credits to shop (inheritance) |
| GET | /agent/shops, /agent/players /agent/* | fetchShops, fetchAgentPlayers | Used by ShopsPage/ShopDetailPage |
| PATCH | /agent/settings/low-balance-warning /agent/* | patchAgentLowBalanceWarning | Low balance warning |
| GET | /agent/broadcasts, /agent/in-app-popups/* /agent/* | broadcasts / popups | Notifications (inherited) |
Cookbook for new devs
Common tasks and how to do them correctly according to project conventions.
- Add export function
src/services/api.ts, callclient.get/post/patch/put(...)(self-attached token). - Declare the response type in
src/types/index.ts(by envelope{ success, data?, message? }). - Component imports functions, self-management
loading/errorequaluseState; error reporttoast.
- Create internal page
src/pages/, wrap the content in<AppLayout>. - Declare
<Route>inApp.tsx, wrap<ProtectedRoute>If you need to log in. - Add the link
NAV_ITEMSincomponents/layout/AppLayout.tsx(putprimary:trueif you want to appear at the bottom nav mobile).
- Add values to the array
AGENT_TABS+ updateisAgentDetailTab(). - Add tab button (call
setActiveTab) and render block{activeTab === '...' && (...)}. - If the tab needs to be fetched separately, use
useEffectdependent[activeTab, agentId]+ flagcancelledto avoid racing.
- Backend saveddecimal rate 0–1(0.05 = 5%). UI input %.
- % → rate:
toRate()/percentInputToRate()(clamp 0–100,/100, toFixed(4)). - rate → %:
toPercent()(×100, toFixed(2)) orformatCostRate()(Intl.NumberFormat style:'percent').
Common traps (read before fixing)
| Trap | Details & how to handle |
|---|---|
| Thought this was a separate app | To befork of Agent Portal: package.jsonname "kioskgaming-agent", README says "Agent". Type/hook prefixAgent*serving SA. |
| Clash localStorage token | STORAGE_KEYS = agent_auth_token/agent_user_dataUsed together with Agent app. Running at the same origin will override each other — separate ports (SA3003). |
| Expect token refresh | Login returns refreshToken but app DOES NOT save/use. 401 = logout now. There is no renewal mechanism. |
| Sai HTTP verb | suspend/unsuspend/terminate/rates/move usePATCH; create & credits uses POST; game-mappings uses PUT. Get the verb fromapi.ts, don't trust the dashboard. |
| Wrong endpoint name | Reality:/super-agent/shops/stats, /super-agent/agents/{id}/rates, .../credits/distribute, game category/game-providers. |
| Dual prefix /agent vs /super-agent | Many legacy features still call/agent/*(shops, players, broadcasts, popups, credits→shop, settings). The backend must receive SA tokens for both. |
| Agent details load slowly | ManagedAgentDetailPagefetch all 3 lists and then filter clients. Do not haveGET /super-agent/agents/:id. |
| Section ≠ tab | /agents /shops /playersare 3 different routes (same component), NOT tabs. Only agent details have a tab (?tab=). |
| SW not running when dev | SW only registers in production. If you want to test PWA, you mustnpm run build + serve build/. |
| Cost rate unit | Backend = decimal 0–1; UI = %. Always usetoRate/toPercent. Enter 5 → save 0.0500. |
| Login profile key | Readres.data.agentORres.data.superAgent— The backend returns a different key depending on the version. |
| Captcha enabled/disabled according to env | Turnstile is only required whenREACT_APP_TURNSTILE_ENABLED='true'& There is a site key. Forgetting to configure will block login when enabled. |