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.
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:prod → cap sync → gradlew 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.
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.tsx → App.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.
createRoot(#root).render(<App/>) in StrictMode. Putdocument.title = ENV.APP_NAMEthen callregisterServiceWorker().
<CashierAuthProvider>wrap<BrowserRouter>, inside is<Routes> + <Toaster/>(react-hot-toast, top right corner).
Provider readscashier_auth_token + cashier_user_datafrom localStorage. MeanwhileisLoading=true→ protected route appearsPageLoadingIndicator.
There is a session → enter/(MyShiftsPage). No session →CashierProtectedRoute redirect /login.
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.
| URL | Page | Protect? | Purpose |
|---|---|---|---|
/login | CashierLoginPage | Public | Login cashier |
/ | MyShiftsPage | 🔒 Protected | List of assigned shifts |
/shifts/:id/opening | OpeningCashPage | 🔒 Protected | Count money to open shift → start shift |
/shifts/:id | ActiveShiftPage | 🔒 Protected | Screen working during shift |
/shifts/:id/cash-drop | CashDropPage | 🔒 Protected | Withdraw money from the safe mid-shift |
/shifts/:id/closing | ClosingCountPage | 🔒 Protected | Counting money to close shifts (blind) |
/shifts/:id/handover | HandoverPage | 🔒 Protected | Handover/handover |
* | Navigate to "/" | — | Any strange URLs → return to the home page |
const { isAuthenticated, isLoading } = useCashierAuth(); if (isLoading) return <PageLoadingIndicator variant="section" />; if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
statusof caMany 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.
cashierLogin() → POST /cashier-auth/loginwith{ username, identifier, password, deviceFingerprint }. Return{ accessToken, cashier, shop }.
login(cashier, token) ghi cashier_auth_token + cashier_user_datago to localStorage, fire eventcashier-auth-token-changed.
ensureCashierDeviceRegistered()→ if you don't have it yetcashier_device_id, callPOST /cashier/devices/registerand save the id. Toast prompts "device registered for approval".
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.
(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.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'— included/api, so paths starting with/cashier... - Request interceptor:read
cashier_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
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)
| Jaw | Method + Path | Note |
|---|---|---|
cashierLogin | POST /cashier-auth/login | sent bothusername & identifier |
fetchCashierMe | GET /cashier/me | pre-declared (rarely used) |
cashierListAssignedShifts | GET /cashier/shifts/assigned | Source of MyShiftsPage |
cashierGetShift | GET /cashier/shifts/:id | Includes transactions + cashDrops |
cashierStartShift | POST /cashier/shifts/:id/start | opening shift with denominations |
cashierCloseShiftBlind | POST /cashier/shifts/:id/close | blind close → returns discrepancy |
cashierInitiateHandover | POST /cashier/shifts/:id/handover | hand it over |
cashierConfirmHandover | POST /cashier/shifts/:id/handover/confirm | receive handover |
cashierCreateCashDrop | POST /cashier/cash-drops | Withdraw money mid-shift |
cashierListCashDrops | GET /cashier/cash-drops?shiftId= | — |
cashierListTransactions | GET /cashier/transactions?shiftId= | Transaction list in shift |
cashierRegisterDevice | POST /cashier/devices/register | Register your tablet |
cashierFetchPlayers | GET /cashier/players?search=&page=&limit= | Look up players |
cashierCreatePlayer | POST /cashier/players | create new players |
cashierDepositCredits | POST /cashier/players/:id/deposit | { amount, payment_method } |
cashierRedeemCredits | POST /cashier/players/:id/redeem | { amount }(no method) |
/apitwiceAPI_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.
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 digitsformatCurrency()useIntl.NumberFormat(USD)
- Type sent to backend:
DenominationItem[] - Pagesfilter outitem
quantity === 0before sending - Grid iscontrolled: parent holds state, transmits
value+onChange - Total calculationin clientinstant; The backend checks itself
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 }
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.
Another state appears in the UI:auto_locked, suspended, no_show, cancelled. In code, "actionable shift" =['active','auto_locked','suspended'].
| status | UI behavior |
|---|---|
scheduled | MyShifts displays the "Start shift" button →/opening. Enter/shifts/:idwill be redirected to opening. |
active | Open/openingpushed back/shifts/:id. Allow deposit/redeem/cash-drop/close/handover. |
auto_locked / suspended | Still in the "operate" group according to the code — the transaction button is still on. |
no_show / cancelled | Color 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
const res = await cashierCloseShiftBlind(id, { denominations: items }); // Expected number is NOT displayed until submission is complete: res.data = { actualClosingCash, expectedClosingCash, discrepancyAmount, discrepancyFlagged, status };
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.
doDeposit(playerId)→ checkamount > 0 → cashierDepositCredits(id, { amount, payment_method }). payment_methodis one'cash' | 'card' | 'bank transfer'(defaultcash). Success → reload players + shift.
doRedeem(playerId) → cashierRedeemCredits(id, { amount }). Do not havePayment method field (cashier pays cash). Sharing the same umbrellaamountwith deposit.
useEffect(() => { const t = setTimeout(() => void loadPlayers(), 250); return () => clearTimeout(t); }, [search]); // keystroke → wait 250ms → GET /cashier/players
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.
CashDropPage→ count the money withdrawn from the safe →cashierCreateCashDrop({ shiftId, denominations, deviceId }). Manager confirms receipt of money at Shop Portal.
initiateWhen the shift isactive/auto_locked/suspended: select shiftschedulednext + count money →cashierInitiateHandover(id, { toShiftId, denominations }).
confirmWhen the shift isscheduled(recipient): count money received →cashierConfirmHandover(id, { denominations })→ self-sang/openingto open the shift.
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.
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'], }, };
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)
# 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.
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.
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_).
| Variable | Default | Meaning |
|---|---|---|
REACT_APP_API_BASE_URL | http://localhost:3001/api | Base backend URL (included/api) |
REACT_APP_APP_NAME | Cashier — Kiosk Gaming | Tab title (set inindex.tsx) |
REACT_APP_TURNSTILE_ENABLED | false | Enable Cloudflare Turnstile (=== 'true') |
REACT_APP_TURNSTILE_SITE_KEY | '' | Site key Turnstile |
CAP_SERVER_URL | https://cashier.kioskservice.club/ | Android WebView URL (atcap sync) |
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.
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.
- Write an export function in
src/services/cashierApi.ts, usecashierClient.get/post(already have tokens). - Path starts with
/cashier...— Are notmore/api(already insidebaseURL). - Wrap
try/catch+asApiErrorPayload(e)to return the envelope{ success, data?, message? }. - Declare the response type right in the service.
- Create
src/pages/XxxPage.tsx, wrap the content with<CashierLayout title=… backTo=…>. - Declare
<Route>inApp.tsxand covered with<Protected>. - Take
iddegreeuseParams; if missing then<Navigate to="/" />. - Show error by
toast(react-hot-toast) instead of alert.
const [denoms, setDenoms] = useState(emptyDenominationGrid()).- Render
<DenominationGrid value={denoms} onChange={setDenoms} />. - Before sending backend, filter
denoms.filter(d => d.quantity > 0). - Show total equals
computeDenominationTotal()/formatCurrency().
npm run build(CRA sinhbuild/— still needed for cap sync even though WebView uses remote).npm run apk:devorapk:prodto setCAP_SERVER_URL+ sync +gradlew assembleDebug.- The APK is inside
android/app/build/outputs/apk/debug/; install on the device. - Logging in for the first time will register the device → ask the store managerbrowse devices.
Common traps (read before fixing)
| Trap | Details & how to handle |
|---|---|
| Thought the app ran offline | server.url in capacitor.config.tslive web pointer → if you lose your connection, the screen goes blank.webDir: 'build'overwritten. |
More/apitwice | API_BASE_URLincluded/api. Path service must begin with/cashier.... |
| There is no refresh token | Access tokens only. 401 → log out now (eventcashier-auth:unauthorized). Do not retry. |
| Tokens are not verified at startup | Provider believes localStorage, does not call/cashier/me. Dead tokens are only revealed on the first request. |
| Device has not been approved | Login is OK but the transaction may be blocked until the manager approves the device in the Shop Portal. |
Forgot to filterquantity > 0 | All money counting pages filter out zero quantity items before sending. Skipping this step may cause errors/junk data. |
| Wrong localStorage key | Yesshop_*andcashier_*. Auth cashier is completely separate from Shop Admin. |
| Turnstile/qrcode installed but not connected | Dependency exists, not rendered yet. Don't think that logging in has anti-bot protection. |
| The real appId is different from the old report | To beclub.kioskservice.kioskgaming.cashier, Notcom.kioskgaming.cashier. |