Cashier · Deep dive

Technical details —Cashier App

Deep dive into the counter cashier application: Create React App + CRACO wrapped in Capacitor to produce Android APK, router protected byreact-router-dom v6, axios client with interceptor, shift lifecycle followsstatus, counting money by face value, deposit/redeem credits. Read the following overview report06-cashier.html.

React 18 (CRA) TypeScript 4.9 CRACO Capacitor 7 (Android) react-router-dom 6 axios

Stack & holistic thinking

Different from Kiosk Frontend (Next.js, state machine in-memory): this is oneCreate classic React AppPackaged by Capacitor into an Android app for counter staff. Grasp the following 6 "strange" characteristics before reading the code.

📟

CRA + CRACO, NOT Next.js

Usereact-scripts 5control over@craco/craco. The script to run iscraco start / craco build. No App Router, no SSR — it's all client-side SPA.

🤖

Target app is Android APK (Capacitor 7)

Same web codebase but the implementation is exactly the sameapp Androidruns on a tablet placed on the counter. Build overnpm run apk:prodcap syncgradlew assembleDebug.

🔌

Useaxios, not fetch

All requests pass through oneAxiosInstance singleton in cashierApi.tswithinterceptorsmanually attach Bearer token and get 401.

🧭

Has real routing (react-router 6)

Contrary to the kiosk frontend, this app uses actual URL routes:/login, /shifts/:id, /shifts/:id/opening…and route guardsCashierProtectedRoute.

🔐

Context Auth + 1 token (no refresh)

CashierAuthProviderkeep logged in state. Onlyaccess token in localStorage, There is no refresh token. 401 → log out now (no retry).

📦

Depends on monorepo internal package

@kioskgaming/uiand@kioskgaming/page-loadingTo befile:../packages/*. CRACO must patchsource-map-loader for packages/uiJust built.

Directory structuresrc/

Flat directory tree according to CRA standards:pages/for each screen,components/, services/, hooks/, utils/. Alias @/NOT used — import using relative path.

kioskgaming_cashier/ ├── capacitor.config.ts # ★ appId + server.url remote (Android) ├── craco.config.js # patch webpack source-map-loader for packages/ui ├── tailwind.config.js # Tailwind v3 + CSS vars hsl() ├── android/ # Android project created by Capacitor (gradlew) ├── scripts/ set-android-app-name.js # rename app dev/prod └── src/ ├── index.tsx # entry: createRoot + registerServiceWorker ├── App.tsx # ★ BrowserRouter + entire Routes ├── pages/ # 1 file = 1 screen │ ├── CashierLoginPage.tsx │ ├── MyShiftsPage.tsx # "/" — shift list │ ├── OpeningCashPage.tsx # /shifts/:id/opening │ ├── ActiveShiftPage.tsx # /shifts/:id — working screen │ ├── CashDropPage.tsx # /shifts/:id/cash-drop │ ├── ClosingCountPage.tsx # /shifts/:id/closing (blind close) │ ├── HandoverPage.tsx # /shifts/:id/handover ├── components/ │ ├── CashierLayout.tsx # khung chung: header + Sign out │ ├── DenominationGrid.tsx # ★ reusable money counting grid │ └── auth/ CashierProtectedRoute.tsx ├── services/ │ └── cashierApi.ts # ★ axios client + all API calls ├── hooks/ useCashierAuth.tsx # Context + provider auth ├── utils/ │ ├── denominations.ts # constant denomination + sum │ └── deviceFingerprint.ts # Device UUID in localStorage ├── config/ env.ts # ENV (REACT_APP_*) └── constants/ index.ts # API_BASE_URL, STORAGE_KEYS, events
💡
Read in what order?

Newbies should open:App.tsx(understand route) →hooks/useCashierAuth.tsx(understand auth) →services/cashierApi.ts(understand API) →pages/ActiveShiftPage.tsx(core screen) →components/DenominationGrid.tsx. These 5 files are ~75% of the system.

Startup screen:index.tsxApp.tsx

A normal CRA SPA: bootstrap React in#root, set the tab title, register the service worker, and then let the router decide on the display.

1
index.tsx initializes React

createRoot(#root).render(<App/>) in StrictMode. Putdocument.title = ENV.APP_NAMEthen callregisterServiceWorker().

2
App.tsx builds the provider tree

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

3
Session restore provider

Provider readscashier_auth_token + cashier_user_datafrom localStorage. MeanwhileisLoading=true→ protected route appearsPageLoadingIndicator.

Navigation router

There is a session → enter/(MyShiftsPage). No session →CashierProtectedRoute redirect /login.

src/index.tsxtsx
document.title = ENV.APP_NAME;

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode><App /></React.StrictMode>
);

registerServiceWorker(); // PWA/offline shell

Routers & protection route

Full definition inApp.tsx. Each business screen is wrapped<Protected>(it isCashierProtectedRoute). Public route only/login.

URLPageProtect?Purpose
/loginCashierLoginPagePublicLogin cashier
/MyShiftsPage🔒 ProtectedList of assigned shifts
/shifts/:id/openingOpeningCashPage🔒 ProtectedCount money to open shift → start shift
/shifts/:idActiveShiftPage🔒 ProtectedScreen working during shift
/shifts/:id/cash-dropCashDropPage🔒 ProtectedWithdraw money from the safe mid-shift
/shifts/:id/closingClosingCountPage🔒 ProtectedCounting money to close shifts (blind)
/shifts/:id/handoverHandoverPage🔒 ProtectedHandover/handover
*Navigate to "/"Any strange URLs → return to the home page
src/components/auth/CashierProtectedRoute.tsxtsx
const { isAuthenticated, isLoading } = useCashierAuth();

if (isLoading) return <PageLoadingIndicator variant="section" />;

if (!isAuthenticated)
  return <Navigate to="/login" replace state={{ from: location }} />;

return <>{children}</>;
🧭
Navigate bystatusof ca

Many pages redirect themselves based on shift status:ActiveShiftPageseestatus==='scheduled'will push back/opening; OpeningCashPageseestatus==='active'will push back/shifts/:id. Guard logic is scattered on each page, not centralized.

Auth, tokens & device identifier

Auth of cashiercompletely separatewith Shop Admin (different localStorage key). Each login is associated with onedevice fingerprintand must register the device for store management to approve.

1
Log in

cashierLogin()POST /cashier-auth/loginwith{ username, identifier, password, deviceFingerprint }. Return{ accessToken, cashier, shop }.

2
Save session

login(cashier, token) ghi cashier_auth_token + cashier_user_datago to localStorage, fire eventcashier-auth-token-changed.

3
Register the device

ensureCashierDeviceRegistered()→ if you don't have it yetcashier_device_id, callPOST /cashier/devices/registerand save the id. Toast prompts "device registered for approval".

!
Expired / 401

Interceptor response of axios catches 401 → fires eventcashier-auth:unauthorized→ provider calledlogout()(delete token + return to login).There is no refresh token.

🔑

STORAGE_KEYS

cashier_auth_token, cashier_user_data, cashier_device_fingerprint, cashier_device_id. Note alsoshop_auth_token / shop_user_datafor Shop Portal — don't be mistaken.

📱

Device fingerprint = UUID

getOrCreateDeviceFingerprint()just createcrypto.randomUUID()then cache in localStorage.Are notmust fingerprint real hardware — deleting storage means changing the identity.

🐛
2 easy points about auth

(1)Provider only trusts localStorage,don't call/cashier/meto verify the token is still aliveon startup — expired tokens are detected only on the first request (401).(2)New devices can log in, but transactions may be blocked until store managementbrowse devicesin Shop Portal (Devices section).

cashierApi.ts — axios singleton

OneAxiosInstancemade available withbaseURL, timeout 15s, and 2 interceptors. All API functions are pure exports (not classes), most are wrappedtry/catchreturns a standard envelope.

PATTERNaxios.create + request/response interceptor
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api'included/api, so paths starting with/cashier...
  • Request interceptor:readcashier_auth_token, if so, attach itAuthorization: Bearer <token>
  • Response interceptor:if 401 →window.dispatchEvent('cashier-auth:unauthorized')then reject
  • Envelope:most functions pay{ success, data?, message?, code? }; error passesasApiErrorPayload()
  • Timeout:15000ms for all requests
src/services/cashierApi.ts (minified)ts
const c = axios.create({ baseURL: API_BASE_URL, timeout: 15000 });

c.interceptors.request.use((config) => {
  const token = localStorage.getItem(STORAGE_KEYS.CASHIER_AUTH_TOKEN);
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

c.interceptors.response.use((r) => r, (error) => {
  if (error.response?.status === 401)
    window.dispatchEvent(new Event('cashier-auth:unauthorized'));
  return Promise.reject(error);
});

Backend endpoint table (afterbaseURLincluded/api)

JawMethod + PathNote
cashierLoginPOST /cashier-auth/loginsent bothusername & identifier
fetchCashierMeGET /cashier/mepre-declared (rarely used)
cashierListAssignedShiftsGET /cashier/shifts/assignedSource of MyShiftsPage
cashierGetShiftGET /cashier/shifts/:idIncludes transactions + cashDrops
cashierStartShiftPOST /cashier/shifts/:id/startopening shift with denominations
cashierCloseShiftBlindPOST /cashier/shifts/:id/closeblind close → returns discrepancy
cashierInitiateHandoverPOST /cashier/shifts/:id/handoverhand it over
cashierConfirmHandoverPOST /cashier/shifts/:id/handover/confirmreceive handover
cashierCreateCashDropPOST /cashier/cash-dropsWithdraw money mid-shift
cashierListCashDropsGET /cashier/cash-drops?shiftId=
cashierListTransactionsGET /cashier/transactions?shiftId=Transaction list in shift
cashierRegisterDevicePOST /cashier/devices/registerRegister your tablet
cashierFetchPlayersGET /cashier/players?search=&page=&limit=Look up players
cashierCreatePlayerPOST /cashier/playerscreate new players
cashierDepositCreditsPOST /cashier/players/:id/deposit{ amount, payment_method }
cashierRedeemCreditsPOST /cashier/players/:id/redeem{ amount }(no method)
📍
Don't add/apitwice

API_BASE_URLhad a suffix/api (http://localhost:3001/api). The paths in the service therefore start straight/cashier.... When adding a new endpoint,Are not prefix /apiagain.

Denomination engine

The heart of every fund operation.utils/denominations.tskeep constant + formula;components/DenominationGrid.tsxis a reusable UI for Opening, Cash drop, Closing and Handover.

Constant & jaw
  • BILL_DENOMINATIONS = [1,2,5,10,20,50,100]
  • COIN_DENOMINATIONS = [0.01,0.05,0.1,0.25,0.5,1]
  • emptyDenominationGrid()→ array{denomination, quantity:0}
  • computeDenominationTotal()→ round to 2 digits
  • formatCurrency()useIntl.NumberFormat (USD)
Data contract
  • Type sent to backend:DenominationItem[]
  • Pagesfilter outitemquantity === 0before sending
  • Grid iscontrolled: parent holds state, transmitsvalue + onChange
  • Total calculationin clientinstant; The backend checks itself
src/utils/denominations.tsts
export function computeDenominationTotal(items: DenominationItem[]): number {
  const total = items.reduce(
    (sum, i) => sum + i.denomination * i.quantity, 0);
  return Math.round(total * 100) / 100; // avoid float errors
}
🧮
Why separate constants into util?

Because the denomination appears in ≥4 screens. Placing the same source ensures that Opening and Closing use the same set of denominations - a prerequisite for the backend to accurately match and detect discrepancies.

Life cycle ca & thestatus

The app does not have a centralized state machine like a kiosk frontend; instead the "state machine" resides on the backend via the fieldshift.status, and the frontend reacts by redirecting and toggling the button.

scheduled active (closing) pending_reconciliation pending_manager_review

Another state appears in the UI:auto_locked, suspended, no_show, cancelled. In code, "actionable shift" =['active','auto_locked','suspended'].

statusUI behavior
scheduledMyShifts displays the "Start shift" button →/opening. Enter/shifts/:idwill be redirected to opening.
activeOpen/openingpushed back/shifts/:id. Allow deposit/redeem/cash-drop/close/handover.
auto_locked / suspendedStill in the "operate" group according to the code — the transaction button is still on.
no_show / cancelledColor badge only; ActiveShiftPage shows a warning "not open for transactions", only for looking up/creating players.
pending_*Red badge (waiting for review / waiting for management approval). After closing the shift.

Close shift with "blind close" style

src/pages/ClosingCountPage.tsxtsx
const res = await cashierCloseShiftBlind(id, { denominations: items });
// Expected number is NOT displayed until submission is complete:
res.data = { actualClosingCash, expectedClosingCash,
            discrepancyAmount, discrepancyFlagged, status };
🙈
"Blind" is intentional

The cashier counts the safeDidn't see the expected number; backend compares and returnsdiscrepancyAmount + discrepancyFlaggedafter submitting. This prevents “counting to match” — an industry-standard internal control measure.

Deposit / Redeem in ActiveShiftPage

Core working screen: look up players (debounce 250ms), load credits, exchange credits, create new players via modal. All trading buttons are enabled only whencanOperate === true.

💰 Deposit (deposit)

doDeposit(playerId)→ checkamount > 0cashierDepositCredits(id, { amount, payment_method }). payment_methodis one'cash' | 'card' | 'bank transfer'(defaultcash). Success → reload players + shift.

💸 Redeem (change)

doRedeem(playerId)cashierRedeemCredits(id, { amount }). Do not havePayment method field (cashier pays cash). Sharing the same umbrellaamountwith deposit.

src/pages/ActiveShiftPage.tsx (lookup with debounce)tsx
useEffect(() => {
  const t = setTimeout(() => void loadPlayers(), 250);
  return () => clearTimeout(t);
}, [search]); // keystroke → wait 250ms → GET /cashier/players
⚠️
Create player: PhoneInput from internal package

Modal "Create player" used<PhoneInput>from@kioskgaming/ui(default countryUS). Needphone OR email(both are not required). Note that the project has installationreact-hook-formBut this form manages the state manuallyuseState.

Handover & Cash drop

Two sub-fund operations, both rotating aroundDenominationGrid. HandoverPageespecially because there is2 modesautomatically deduced from ca.'s status.

A
Cash drop (mid shift)

CashDropPage→ count the money withdrawn from the safe →cashierCreateCashDrop({ shiftId, denominations, deviceId }). Manager confirms receipt of money at Shop Portal.

B
Handover — mode initiate

When the shift isactive/auto_locked/suspended: select shiftschedulednext + count money →cashierInitiateHandover(id, { toShiftId, denominations }).

C
Handover — mode confirm

When the shift isscheduled(recipient): count money received →cashierConfirmHandover(id, { denominations })→ self-sang/openingto open the shift.

src/pages/HandoverPage.tsx (choose mode according to status)tsx
if (['active','auto_locked','suspended'].includes(current.status))
  setMode('initiate');
else if (current.status === 'scheduled')
  setMode('confirm');
else setMode('none'); // do not allow handover

Capacitor / Android — the biggest difference

Here's what makes this repo different from every other web app in the system: it's packaged intoAPK AndroidRuns on tablet counter. But there's an important surprise about how the app loads content.

capacitor.config.tsts
const remoteUrl = process.env.CAP_SERVER_URL?.trim()
  || 'https://cashier.kioskservice.club/';

const config: CapacitorConfig = {
  appId: 'club.kioskservice.kioskgaming.cashier',
  appName, // '[Dev] Cashier...' or 'Cashier - Kiosk Gaming'
  webDir: 'build',
  server: {
    url: remoteUrl, // ★ load from LIVE web, not from local build!
    allowNavigation: ['dev-cashier.kioskservice.club', 'cashier.kioskservice.club'],
  },
};
🚨
APK is a "shell", content downloaded from a remote server

There is a declarationwebDir: 'build'Butserver.url overwrite: Android WebView will load straight awayhttps://cashier.kioskservice.club/(or dev). MeanUpdate website = update app immediately, no need to rebuild the APK — but that meansapp DOES NOT run offline; If you lose your network, the screen will turn white. (Different from the description "runs offline" in the overview report.)

Actual build flow (script in package.json)

Build APKbash
# DEV: point WebView to dev-cashier + rename "[Dev] ..."
npm run apk:dev
#  └→ CAP_SERVER_URL=https://dev-cashier... cap sync android
#     && node scripts/set-android-app-name.js dev
#     && cd android && ./gradlew assembleDebug

# PROD: points to cashier.kioskservice.club
npm run apk:prod   # = apk (default)

appId

club.kioskservice.kioskgaming.cashier— this is the real package id (notcom.kioskgaming.cashier).

Service worker

registerServiceWorker()runs on the web; In WebrView remote it supports basic cache/PWA, but it doesn't make up for itserver.urlneed network.

gradlew assembleDebug

Both dev and prod builddebug APK(not seen yetassembleRelease/digital signature in script).

Tailwind v3 & CRACO

Other kiosk frontend (Tailwind v4 no config): this isTailwind v3Havetailwind.config.js + postcss.config.js, using design token stylehsl(var(--…)). Build via CRACO to patch webpack.

craco.config.jsjs
const patch = require('../packages/ui/scripts/craco-patch-source-map-loader-ui.cjs');

module.exports = {
  webpack: {
    configure: (cfg) => { patch(cfg); return cfg; },
  },
};
🎨

Color token via CSS var

tailwind.config.js map background, primary, accent… sang hsl(var(--…)). Content is scanned../packages/ui/dist/**/*.jsto keep the class of the UI package.

🩹

Why do we need CRACO?

react-scriptsDo not allow webpack to be edited. CRACO inserts patchsource-map-loadertopackages/ui(formfile:) does not cause the build to break due to missing source map.

🎨
UI color style

The login page uses an amber–emerald gradient (from-amber-50 … to-emerald-50), main button coloramber-600; The screen in the case uses cardboardstoneneutral + buttonemerald-600for positive action androse-600close the shift. Icon taken fromlucide-react.

Config & environment variable

Stay focusedsrc/config/env.ts. According to CRA standards, variables must have a prefixREACT_APP_just entered the bundle (differently used by Next.jsNEXT_PUBLIC_).

VariableDefaultMeaning
REACT_APP_API_BASE_URLhttp://localhost:3001/apiBase backend URL (included/api)
REACT_APP_APP_NAMECashier — Kiosk GamingTab title (set inindex.tsx)
REACT_APP_TURNSTILE_ENABLEDfalseEnable Cloudflare Turnstile (=== 'true')
REACT_APP_TURNSTILE_SITE_KEY''Site key Turnstile
CAP_SERVER_URLhttps://cashier.kioskservice.club/Android WebView URL (atcap sync)
🧩
Turnstile declared but not yet connected to login

Repo has it installed@marsidev/react-turnstileand readTURNSTILE_* in env.ts, ButCashierLoginPagepresentlyTurnstile widget has not been rendered yet. If you need to enable anti-bot at login, this is the point that must be appended. (Dependencyqrcodealso available for future needs.)

Test setup

Repo has the set@testing-library/*via CRA/Jest but currently there is no actual test file in the codebase.

🧪

What's up?

Run bycraco test(CRA Jest).setupTests.tsloaded@testing-library/jest-dom. Ready to write tests but don't have files yet*.test.tsx in repo.

⚠️

Space

There is no test yetDenominationGrid, deposit/redeem, or router guard. The core part of the money business is currently not covered by testing.

Scriptsbash
npm start          # craco start → http://localhost:3000
npm run build      # craco build → build/ folder
npm test           # craco test (Jest watch)
npm run apk:prod   # cap sync + gradlew assembleDebug

Cookbook for new devs

Common tasks follow project conventions.

HOW-TOAdd a new API endpoint
  1. Write an export function insrc/services/cashierApi.ts, usecashierClient.get/post(already have tokens).
  2. Path starts with/cashier...Are notmore/api(already insidebaseURL).
  3. Wraptry/catch + asApiErrorPayload(e)to return the envelope{ success, data?, message? }.
  4. Declare the response type right in the service.
HOW-TOAdd a protected page
  1. Createsrc/pages/XxxPage.tsx, wrap the content with<CashierLayout title=… backTo=…>.
  2. Declare<Route> in App.tsxand covered with<Protected>.
  3. TakeiddegreeuseParams; if missing then<Navigate to="/" />.
  4. Show error bytoast(react-hot-toast) instead of alert.
HOW-TOReuse the money counting net
  1. const [denoms, setDenoms] = useState(emptyDenominationGrid()).
  2. Render <DenominationGrid value={denoms} onChange={setDenoms} />.
  3. Before sending backend, filterdenoms.filter(d => d.quantity > 0).
  4. Show total equalscomputeDenominationTotal() / formatCurrency().
HOW-TOBuild & Install APK on tablet counter
  1. npm run build (CRA sinh build/— still needed for cap sync even though WebView uses remote).
  2. npm run apk:devorapk:prodto setCAP_SERVER_URL + sync + gradlew assembleDebug.
  3. The APK is insideandroid/app/build/outputs/apk/debug/; install on the device.
  4. Logging in for the first time will register the device → ask the store managerbrowse devices.

Common traps (read before fixing)

TrapDetails & how to handle
Thought the app ran offlineserver.url in capacitor.config.tslive web pointer → if you lose your connection, the screen goes blank.webDir: 'build'overwritten.
More/apitwiceAPI_BASE_URLincluded/api. Path service must begin with/cashier....
There is no refresh tokenAccess tokens only. 401 → log out now (eventcashier-auth:unauthorized). Do not retry.
Tokens are not verified at startupProvider believes localStorage, does not call/cashier/me. Dead tokens are only revealed on the first request.
Device has not been approvedLogin is OK but the transaction may be blocked until the manager approves the device in the Shop Portal.
Forgot to filterquantity > 0All money counting pages filter out zero quantity items before sending. Skipping this step may cause errors/junk data.
Wrong localStorage keyYesshop_*andcashier_*. Auth cashier is completely separate from Shop Admin.
Turnstile/qrcode installed but not connectedDependency exists, not rendered yet. Don't think that logging in has anti-bot protection.
The real appId is different from the old reportTo beclub.kioskservice.kioskgaming.cashier, Notcom.kioskgaming.cashier.