Shop · Deep dive

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.

CRA + Craco 7 TypeScript 4.9 React Router 6.8 Tailwind 3.2 axios 1.3

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.

Super Agent Agent Shop (you) Cashier Player

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.

🧭
Read in what order?

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.

src/ ├── index.tsx # Entry: createRoot + StrictMode + registerServiceWorker (PWA) ├── App.tsx # ★ BrowserRouter + all Routes + AuthProvider + Toaster ├── index.css # @tailwind + HSL variable :root + style for PhoneInput ├── pages/ # 11 page component (1 route ↔ 1 file) │ ├── DashboardPage.tsx #4 stat card + overview + recent transactions │ ├── PlayersPage.tsx # List + 2 modal (create / transfer) │ ├── PlayerDetailPage.tsx # /players/:playerId │ ├── ShopCreditPurchaseModal.tsx # Buy credits (dark UI, 0x + other) │ ├── TransactionsPage.tsx # Ledger wallet shop (200 lines) │ ├── KioskDomainPage.tsx # Subdomain create/edit │ ├── SettingsPage.tsx # Low balance warning │ ├── SendBroadcastPage.tsx # Send bell notification │ ├── SendPlayerPopupPage.tsx # Send in-app popup │ ├── TransferToPlayerPage.tsx # Legacy (/transfer/legacy) │ └── ShopPaymentReturnPage.tsx # 3 trang return (success/cancel/error) ├── components/ │ ├── ShopPlayerLinkByPhonePanel.tsx # ★ Lookup core + OTP (shared 2 modals) │ ├── ShopNotificationBell.tsx # Broadcast bell (poll 2 minutes) │ ├── ShopInAppPopupGate.tsx # Global gate popup (createPortal) │ ├── auth/ # LoginPage, ProtectedRoute │ ├── credits/ # Trigger/Modal to buy credits + payment category │ ├── layout/ AppLayout.tsx # Sidebar + header + bottom nav + wallet │ └── dashboard/, home/, players/, ui/ ├── services/ api.ts # ★ 1 axios client + ~35 API functions ├── hooks/ useAuth.tsx, useShopWalletTransactions.ts, useSupportedZeroxMethods.ts ├── types/ index.ts # Entire interface domain ├── constants/ index.ts # API_BASE_URL, STORAGE_KEYS, event names ├── config/ env.ts # Read process.env.REACT_APP_* └── utils/ phone.ts, shopWalletLedger.ts, sanitizeUser.ts …

Startup Screen & render model

It is a pure client CRA SPA (no SSR).index.tsx render <App/> in StrictModeand register a service worker (PWA).

1
index.tsx mount root

ReactDOM.createRoot(#root).render(<App/>) + registerServiceWorker(). Global CSS fromindex.css.

2
App.tsx builds the framework

<AuthProvider>wrap<BrowserRouter> + <Routes> + <Toaster/>(react-hot-toast, top right corner).

3
AuthProvider restores the session

useEffectreadshop_auth_token + shop_user_datafrom localStorage. While reading,isLoading=true→ ProtectedRoute shows spinner.

Render the current route

If authenticated →DashboardPage(wrapAppLayout). Not yet authenticated → redirect/loginattachedstate.fromto return after logging in.

💡
Why wrap AuthProvider OUTSIDE BrowserRouter?

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

PathComponentGuardNote
/loginLoginPagePublic; if auth → redirect/
/DashboardPageStat cards + overview
/playersPlayersPageList + modal create/transfer
/players/:playerIdPlayerDetailPagePlayer details
/transactionsTransactionsPageLedger wallet shop
/cashiersCashiersPageCashier management
/shiftsShiftsPageShift
/cash-settlement/depositsCounterDepositsLedgerPageLoad counters
/online-transactionsOnlineBalanceTransactionsPageUSD wallet
/withdrawalsWithdrawalsApprovalPageBrowse and withdraw players
/purchaseredirect /Buy via modal
/purchaseRedirect → /
/kiosk-domainKioskDomainPageSubdomain
/settingsSettingsPageLow balance warning
/popups/sendSendPlayerPopupPageSend in-app popup
/broadcasts/sendSendBroadcastPageSend bell
/transferRedirect → /players
/transfer/legacyTransferToPlayerPageTrang transfer legacy
/payment/success|cancelled|errorShopPayment*PageReturn from payment provider
*Catch-all → /
src/components/auth/ProtectedRoute.tsxtsx
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}</>;
⚠️
Guard is client-side, does not replace the backend

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

1
Submit form login

react-hook-formvalidate email + password (≥6 characters). IfTURNSTILE_ENABLED→ captcha token required.

2
Call API

loginWithEmailPassword()POST /shop-auth/loginwith{ email, password, captchaToken? }.

3
Standardize profiles

toShopProfile(res.data.shop)map both camelCase & snake_case (costRate/cost_rate, agentId/agent_id).

4
login() saves state

Ghi shop_auth_token + shop_user_dataGo to localStorage, set context, dispatchshop-auth-token-changed.

5
Navigation

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:unauthorizedAuthProvidercalllogout()→ delete token + return to login.Are notThere is a refresh mechanism.

🐛
refreshToken returned but not used

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.

PATTERNSingle axios client + interceptors + envelope
  • 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 headersContent-Type: application/json
  • Request interceptor:readshop_auth_token→ mountAuthorization: Bearer
  • Response interceptor:If401window.dispatchEvent('auth:unauthorized')then reject
  • Standard envelope: { success, message?, data?, code? }— many additional pay functionspagination
src/services/api.ts (shortened)ts
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)

GroupTypical functionEndpoint
AuthloginWithEmailPasswordPOST /shop-auth/login
DashboardfetchDashboardGET /shop/dashboard
PlayersfetchShopPlayers, fetchShopPlayer, lookupPlayerByPhone/shop/players, /shop/players/:id, /shop/players/lookup
OTP registrationregisterPlayerInitiate, ...Resend, verifyPlayerRegistration/shop/players/register-initiate|resend|verify
OTP transferinitiatePlayerTransfer, verifyPlayerTransfer/shop/players/transfer/initiate|verify|resend
Credits playerpostShopPlayerCreditsPOST /shop/players/:id/credits
Buy creditspurchaseShopCreditsPOST /shop/credits/purchase
LedgerfetchShopWalletTransactionsGET /shop/transactions
Kiosk domainfetch/create/patchShopKioskDomain/shop/kiosk-domain (GET/POST/PATCH)
NotificationfetchShopBroadcasts, createShopInAppPopup/shop/broadcasts, /shop/in-app-popups
Payment 0xuseSupportedZeroxMethodsGET /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).

What's up?
  • 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)
Do not have
  • Redux / Zustand / Jotai
  • React Query / SWR
  • Global wallet/player store (each page is fetched by itself)
  • Alias path @/*(relative import)
EventAi dispatchWho listens/effects
auth:unauthorizedresponse interceptor (401)AuthProvider → logout()
shop-auth-token-changedlogin() / logout()ShopInAppPopupGate refetch popup
shop-credit-balance-refreshdispatchShopCreditBalanceRefresh()after deposit/return paymentAppLayout refetch sidebar balance
🔄
Two other balance refresh mechanisms

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.

enter phone lookupPlayerByPhone() not_found | unassigned | in_shop | other_shop
StatusMeaningUI actions
not_foundThere is no account with this number yet"Send OTP — create & link" button → registration flow
unassignedHave an account but don't belong to any shop yet"Send OTP — link to this shop" button → registration flow
in_shopBelongs to this shopNo OTP needed;onInShop(phone)→ focus search in the table
other_shopBelongs to another shopSwitch to transfer stream (player must approve OTP)
🔀
register-initiate can also return "transfer_required"

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.

otpFlowModeWhenInitialization APIAPI verify
registrationnot_found / unassignedregisterPlayerInitiate({phone,email?,verification_method})verifyPlayerRegistration(id, otp)
transferother_shopinitiatePlayerTransfer(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.

🔒
Top up credits for players WITHOUT OTP

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.

utils/shopWalletLedger.ts — normalizeMeta (shortened)ts
// 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;
}
🧩
Type ledger is intentionally "wide" to withstand both cases

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.

1
Enter credits (integer)

Filtered inputreplace(/\D/g,''). estimatedCharge = creditNum × safeRate(rate clamped to [0,1]).

2
Select the methods tab

Recommend= methods 0x (useSupportedZeroxMethods('deposit'), with 1 day cache).Other = btcpay/linkmepay (BTC, LTC, DOGE, PYUSD, Cash App).

3
Validate by method

zerox_*: $5–$10,000. cashapp: indicates internal levelsECASH_APP_ALLOWED_AMOUNTS. Other: minimum $10.

4
Create order

purchaseShopCredits(creditNum, mappedMethod)POST /shop/credits/purchase. mapUiPaymentIdToAgentPaymentMethodchange UI id to backend string (egbtcpay→btc_onchain).

5
Pay

Presentlyresult.paymentUrlin iframe (sandbox) + "Open in new tab" button. Adding credits is handled by the webhook backend after the provider confirms.

🪙
Filter displayed tickers

filterShopVisiblePurchaseOptionsjust for showBTCand any variationsUSDT (regex /^USDT($|[\s(])/, matches "USDT (TRC20)"). Other methods are hidden even though the API returns them.

🌐
SHOP_FRONTEND_URL must be true to redirect

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.

KioskDomainPage.tsx — subdomainLabelFromFqdnts
// 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] || '';
}
⚠️
Missing SHOP_KIOSK_BASE_DOMAIN → blocking UI

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.

🔗
Body supports links

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

src/index.css + tailwind.config.jscss
@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.

VariableDefaultMeaning
REACT_APP_API_BASE_URLhttp://localhost:3001/apiBase URL (included/api)
REACT_APP_APP_NAMEKiosk Gaming — ShopApp name
REACT_APP_TURNSTILE_ENABLEDfalse'true'Just enabled captcha login
REACT_APP_TURNSTILE_SITE_KEYSite key Cloudflare Turnstile
PORT3004Dev server port (CRA)
📄
Different from kiosk frontend in terms of base URL

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

Scripts (package.json)bash
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
🧪
No real test yet

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.

HOW-TOCall a new backend API
  1. Add async function insrc/services/api.tsuseclient.get/post/patch(start path/shop/..., DO NOT add/api).
  2. Declare interface response in src/types/index.ts(subject to camelCase/snake_case if needed).
  3. In page/component, self-managedloading/errorequaluseState + useCallback; error reporttoast.
HOW-TOAdd a new route
  1. Createsrc/pages/MyPage.tsx, wrap the content in<AppLayout>.
  2. Declare <Route> in App.tsx, wrap<ProtectedRoute>if auth is needed.
  3. If you want to go to the sidebar, add an itemNAV_ITEMS in AppLayout.tsx(putprimary: trueto show in bottom nav mobile).
HOW-TOForce wallet balance to refresh after money manipulation
  1. After successful deposit/transfer, calldispatchShopCreditBalanceRefresh()(fromconstants/).
  2. AppLayoutis listening for this event → callback itselffetchDashboard()for sidebar balance card.
  3. In the current page, call your local reload function (silent mode if you don't want a spinner).
HOW-TOAdd step to panel link player
  1. All lookup/OTP logic resides withinShopPlayerLinkByPhonePanel.tsx— edit here, don't clone.
  2. Remember to handle all 4lookupStatusand bothotpFlowMode.
  3. Modal inPlayersPageusekeygradually increase to remount panel (clear state) — keep this pattern when reopening the modal.

Common traps (read before fixing)

TrapDetails & how to handle
Double /apiAPI_BASE_URLhad/api. The new function is write-only/shop/..., don't add/apiagain.
refreshToken is droppedClient only savesaccessToken. 401 = log out, no refresh. If you need a long session, you have to add the logic yourself.
Two different balance thresholdsSidebar hardcode 100 credits; Settings is USD threshold (min $50). Don't confuse the two numbers.
register-initiate returns transfer_requiredIn flow create can still be encounteredother_shop— handles both body 2xx and 4xx errors (available in the panel).
Ledger 2 casingField has both camelCase & snake_case, sometimes located inmetadata. Always use helpergetPresentation/normalizeMeta.
String mixed with VietnameseShopCreditPurchaseModalthere is a string "Loading payment method..." in the middle of the English UI. Note language consistency when editing.
Filter payment tickersOnly BTC & USDT* appears (filterShopVisiblePurchaseOptions). Other methods are hidden even though the API returns them.
SHOP_FRONTEND_URL / SHOP_KIOSK_BASE_DOMAINTwo 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 yetnpm testIt works but there are no cases. Pure helpers are where you should start writing tests.