Kỹ thuật chi tiết — Cashier App
Đi sâu vào ứng dụng thu ngân tại quầy: Create React App + CRACO bọc trong Capacitor để ra APK Android, router bảo vệ bằng react-router-dom v6, client axios với interceptor, vòng đời ca làm việc theo status, đếm tiền theo mệnh giá, deposit/redeem credits. Đọc sau báo cáo tổng quan 06-cashier.html.
Stack & tư duy tổng thể
Khác hẳn Kiosk Frontend (Next.js, state machine in-memory): đây là một Create React App cổ điển được Capacitor đóng gói thành app Android cho nhân viên quầy. Nắm 6 đặc điểm "lạ" sau trước khi đọc code.
CRA + CRACO, KHÔNG phải Next.js
Dùng react-scripts 5 điều khiển qua @craco/craco. Script chạy là craco start / craco build. Không có App Router, không SSR — toàn bộ là SPA client-side.
App đích là APK Android (Capacitor 7)
Cùng codebase web nhưng triển khai thật là một app Android chạy trên tablet đặt ở quầy. Build qua npm run apk:prod → cap sync → gradlew assembleDebug.
Dùng axios, không phải fetch
Tất cả request qua một AxiosInstance singleton trong cashierApi.ts với interceptors tự gắn Bearer token và bắt 401.
Có routing thật (react-router 6)
Trái với kiosk frontend, app này dùng URL routes thực sự: /login, /shifts/:id, /shifts/:id/opening… và route guard CashierProtectedRoute.
Context Auth + 1 token (không refresh)
CashierAuthProvider giữ state đăng nhập. Chỉ có access token trong localStorage, không có refresh token. 401 → đăng xuất ngay (không retry).
Phụ thuộc package nội bộ monorepo
@kioskgaming/ui và @kioskgaming/page-loading là file:../packages/*. CRACO phải vá source-map-loader cho packages/ui mới build được.
Cấu trúc thư mục src/
Cây thư mục phẳng theo chuẩn CRA: pages/ cho từng màn hình, components/, services/, hooks/, utils/. Alias @/ KHÔNG được dùng — import bằng đường dẫn tương đối.
Người mới nên mở: App.tsx (hiểu route) → hooks/useCashierAuth.tsx (hiểu auth) → services/cashierApi.ts (hiểu API) → pages/ActiveShiftPage.tsx (màn hình lõi) → components/DenominationGrid.tsx. 5 file này là ~75% hệ thống.
Màn hình khởi động: index.tsx → App.tsx
Một SPA CRA bình thường: bootstrap React vào #root, đặt tiêu đề tab, đăng ký service worker, rồi để router quyết định màn hình.
createRoot(#root).render(<App/>) trong StrictMode. Đặt document.title = ENV.APP_NAME rồi gọi registerServiceWorker().
<CashierAuthProvider> bọc <BrowserRouter>, bên trong là <Routes> + <Toaster/> (react-hot-toast, góc trên phải).
Provider đọc cashier_auth_token + cashier_user_data từ localStorage. Trong lúc đó isLoading=true → route bảo vệ hiện PageLoadingIndicator.
Có phiên → vào / (MyShiftsPage). Không có phiên → 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
Router & route bảo vệ
Toàn bộ định nghĩa trong App.tsx. Mỗi màn hình nghiệp vụ được bọc <Protected> (chính là CashierProtectedRoute). Route public chỉ có /login.
| URL | Page | Bảo vệ? | Mục đích |
|---|---|---|---|
/login | CashierLoginPage | Public | Đăng nhập thu ngân |
/ | MyShiftsPage | 🔒 Protected | Danh sách ca được phân công |
/shifts/:id/opening | OpeningCashPage | 🔒 Protected | Đếm tiền mở ca → start shift |
/shifts/:id | ActiveShiftPage | 🔒 Protected | Màn hình làm việc trong ca |
/shifts/:id/cash-drop | CashDropPage | 🔒 Protected | Rút tiền khỏi két giữa ca |
/shifts/:id/closing | ClosingCountPage | 🔒 Protected | Đếm tiền đóng ca (blind) |
/shifts/:id/handover | HandoverPage | 🔒 Protected | Bàn giao / nhận bàn giao |
* | Navigate to "/" | — | Mọi URL lạ → về trang chủ |
const { isAuthenticated, isLoading } = useCashierAuth(); if (isLoading) return <PageLoadingIndicator variant="section" />; if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />; return <>{children}</>;
status của caNhiều page tự redirect dựa trên trạng thái ca: ActiveShiftPage thấy status==='scheduled' sẽ đẩy về /opening; OpeningCashPage thấy status==='active' sẽ đẩy về /shifts/:id. Logic guard nằm rải trong từng page, không tập trung.
Auth, token & định danh thiết bị
Auth của cashier tách biệt hoàn toàn với Shop Admin (key localStorage khác nhau). Mỗi lần đăng nhập gắn kèm một device fingerprint và phải đăng ký thiết bị để quản lý cửa hàng duyệt.
cashierLogin() → POST /cashier-auth/login với { username, identifier, password, deviceFingerprint }. Trả về { accessToken, cashier, shop }.
login(cashier, token) ghi cashier_auth_token + cashier_user_data vào localStorage, bắn event cashier-auth-token-changed.
ensureCashierDeviceRegistered() → nếu chưa có cashier_device_id, gọi POST /cashier/devices/register và lưu lại id. Toast nhắc "device registered for approval".
Interceptor response của axios bắt 401 → bắn event cashier-auth:unauthorized → provider gọi logout() (xoá token + về login). Không có refresh token.
STORAGE_KEYS
cashier_auth_token, cashier_user_data, cashier_device_fingerprint, cashier_device_id. Lưu ý còn shop_auth_token / shop_user_data cho Shop Portal — đừng nhầm.
Device fingerprint = UUID
getOrCreateDeviceFingerprint() chỉ tạo crypto.randomUUID() rồi cache trong localStorage. Không phải fingerprint phần cứng thật — xoá storage là đổi định danh.
(1) Provider chỉ tin localStorage, không gọi /cashier/me để verify token còn sống khi khởi động — token hết hạn chỉ bị phát hiện ở request đầu tiên (401). (2) Thiết bị mới đăng nhập được nhưng giao dịch có thể bị chặn cho tới khi quản lý cửa hàng duyệt thiết bị trong Shop Portal (mục Devices).
cashierApi.ts — axios singleton
Một AxiosInstance tạo sẵn với baseURL, timeout 15s, và 2 interceptor. Mọi hàm API là export thuần (không phải class), đa số bọc try/catch trả về một envelope chuẩn.
- Base URL:
REACT_APP_API_BASE_URL || 'http://localhost:3001/api'— đã gồm/api, nên các path bắt đầu bằng/cashier... - Request interceptor: đọc
cashier_auth_token, nếu có thì gắnAuthorization: Bearer <token> - Response interceptor: nếu 401 →
window.dispatchEvent('cashier-auth:unauthorized')rồi reject - Envelope: hầu hết hàm trả
{ success, data?, message?, code? }; lỗi đi quaasApiErrorPayload() - Timeout: 15000ms cho mọi request
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); });
Bảng endpoint backend (sau baseURL đã gồm /api)
| Hàm | Method + Path | Ghi chú |
|---|---|---|
cashierLogin | POST /cashier-auth/login | gửi cả username & identifier |
fetchCashierMe | GET /cashier/me | khai báo sẵn (ít dùng) |
cashierListAssignedShifts | GET /cashier/shifts/assigned | nguồn của MyShiftsPage |
cashierGetShift | GET /cashier/shifts/:id | kèm transactions + cashDrops |
cashierStartShift | POST /cashier/shifts/:id/start | mở ca với denominations |
cashierCloseShiftBlind | POST /cashier/shifts/:id/close | blind close → trả discrepancy |
cashierInitiateHandover | POST /cashier/shifts/:id/handover | bàn giao đi |
cashierConfirmHandover | POST /cashier/shifts/:id/handover/confirm | nhận bàn giao |
cashierCreateCashDrop | POST /cashier/cash-drops | rút tiền giữa ca |
cashierListCashDrops | GET /cashier/cash-drops?shiftId= | — |
cashierListTransactions | GET /cashier/transactions?shiftId= | danh sách giao dịch trong ca |
cashierRegisterDevice | POST /cashier/devices/register | đăng ký tablet |
cashierFetchPlayers | GET /cashier/players?search=&page=&limit= | tra cứu người chơi |
cashierCreatePlayer | POST /cashier/players | tạo người chơi mới |
cashierDepositCredits | POST /cashier/players/:id/deposit | { amount, payment_method } |
cashierRedeemCredits | POST /cashier/players/:id/redeem | { amount } (không có method) |
/api hai lầnAPI_BASE_URL đã có hậu tố /api (http://localhost:3001/api). Các path trong service vì thế bắt đầu thẳng bằng /cashier.... Khi thêm endpoint mới, không prefix /api nữa.
Denomination engine
Trái tim của mọi thao tác quỹ. utils/denominations.ts giữ hằng số + công thức; components/DenominationGrid.tsx là UI tái dùng ở Opening, Cash drop, Closing và Handover.
BILL_DENOMINATIONS = [1,2,5,10,20,50,100]COIN_DENOMINATIONS = [0.01,0.05,0.1,0.25,0.5,1]emptyDenominationGrid()→ mảng{denomination, quantity:0}computeDenominationTotal()→ làm tròn 2 chữ sốformatCurrency()dùngIntl.NumberFormat(USD)
- Type gửi lên backend:
DenominationItem[] - Các page lọc bỏ mục
quantity === 0trước khi gửi - Grid là controlled: cha giữ state, truyền
value+onChange - Tổng tính ở client tức thì; backend tự kiểm lại
export function computeDenominationTotal(items: DenominationItem[]): number { const total = items.reduce( (sum, i) => sum + i.denomination * i.quantity, 0); return Math.round(total * 100) / 100; // tránh lỗi float }
Vì mệnh giá xuất hiện ở ≥4 màn hình. Đặt chung một nguồn đảm bảo Opening và Closing dùng đúng cùng bộ mệnh giá — điều kiện tiên quyết để backend so khớp và phát hiện chênh lệch (discrepancy) chính xác.
Vòng đời ca & các status
App không có một state machine tập trung như kiosk frontend; thay vào đó "máy trạng thái" nằm ở backend qua trường shift.status, còn frontend phản ứng bằng cách redirect và bật/tắt nút.
Trạng thái khác xuất hiện trong UI: auto_locked, suspended, no_show, cancelled. Trong code, "ca thao tác được" = ['active','auto_locked','suspended'].
| status | Hành vi UI |
|---|---|
scheduled | MyShifts hiện nút "Start shift" → /opening. Vào /shifts/:id sẽ bị redirect tới opening. |
active | Mở /opening bị đẩy về /shifts/:id. Cho phép deposit/redeem/cash-drop/close/handover. |
auto_locked / suspended | Vẫn nằm trong nhóm "operate được" theo code — nút giao dịch vẫn bật. |
no_show / cancelled | Chỉ có badge màu; ActiveShiftPage hiện cảnh báo "không mở cho giao dịch", chỉ cho tra cứu/tạo player. |
pending_* | Badge đỏ (chờ đối soát / chờ quản lý duyệt). Sau khi đóng ca. |
Đóng ca kiểu "blind close"
const res = await cashierCloseShiftBlind(id, { denominations: items }); // Số kỳ vọng KHÔNG hiển thị cho tới khi submit xong: res.data = { actualClosingCash, expectedClosingCash, discrepancyAmount, discrepancyFlagged, status };
Thu ngân đếm két mà không thấy số kỳ vọng; backend so sánh và trả về discrepancyAmount + discrepancyFlagged sau khi submit. Điều này chống việc "đếm cho khớp" — một biện pháp kiểm soát nội bộ chuẩn ngành.
Deposit / Redeem trong ActiveShiftPage
Màn hình làm việc lõi: tra cứu người chơi (debounce 250ms), nạp credits, đổi credits, tạo người chơi mới qua modal. Tất cả nút giao dịch chỉ bật khi canOperate === true.
doDeposit(playerId) → kiểm amount > 0 → cashierDepositCredits(id, { amount, payment_method }). payment_method là một trong 'cash' | 'card' | 'bank transfer' (mặc định cash). Thành công → reload players + shift.
doRedeem(playerId) → cashierRedeemCredits(id, { amount }). Không có trường phương thức thanh toán (thu ngân chi tiền mặt). Cùng dùng chung ô amount với deposit.
useEffect(() => { const t = setTimeout(() => void loadPlayers(), 250); return () => clearTimeout(t); }, [search]); // gõ phím → chờ 250ms → GET /cashier/players
Modal "Create player" dùng <PhoneInput> từ @kioskgaming/ui (mặc định country US). Cần phone HOẶC email (không bắt buộc cả hai). Lưu ý dự án có cài react-hook-form nhưng form này lại quản state thủ công bằng useState.
Handover & Cash drop
Hai thao tác quỹ phụ, đều xoay quanh DenominationGrid. HandoverPage đặc biệt vì có 2 chế độ tự suy từ status của ca.
CashDropPage → đếm tiền rút khỏi két → cashierCreateCashDrop({ shiftId, denominations, deviceId }). Quản lý xác nhận nhận tiền ở Shop Portal.
initiateKhi ca đang active/auto_locked/suspended: chọn ca scheduled kế tiếp + đếm tiền → cashierInitiateHandover(id, { toShiftId, denominations }).
confirmKhi ca đang scheduled (người nhận): đếm tiền nhận được → cashierConfirmHandover(id, { denominations }) → tự sang /opening để mở ca.
if (['active','auto_locked','suspended'].includes(current.status)) setMode('initiate'); else if (current.status === 'scheduled') setMode('confirm'); else setMode('none'); // không cho bàn giao
Capacitor / Android — điểm khác biệt lớn nhất
Đây là thứ khiến repo này khác mọi app web khác trong hệ thống: nó được đóng gói thành APK Android chạy trên tablet quầy. Nhưng có một bất ngờ quan trọng về cách app nạp nội dung.
const remoteUrl = process.env.CAP_SERVER_URL?.trim() || 'https://cashier.kioskservice.club/'; const config: CapacitorConfig = { appId: 'club.kioskservice.kioskgaming.cashier', appName, // '[Dev] Cashier...' hoặc 'Cashier - Kiosk Gaming' webDir: 'build', server: { url: remoteUrl, // ★ nạp từ web LIVE, không từ build local! allowNavigation: ['dev-cashier.kioskservice.club', 'cashier.kioskservice.club'], }, };
Có khai báo webDir: 'build' nhưng server.url ghi đè: WebView Android sẽ tải thẳng https://cashier.kioskservice.club/ (hoặc dev). Nghĩa là cập nhật web = cập nhật app ngay, không cần build lại APK — nhưng cũng nghĩa là app KHÔNG chạy offline; mất mạng là trắng màn hình. (Khác với mô tả "chạy offline" ở báo cáo tổng quan.)
Build flow thực tế (script trong package.json)
# DEV: trỏ WebView về dev-cashier + đổi tên "[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: trỏ về cashier.kioskservice.club npm run apk:prod # = apk (mặc định)
appId
club.kioskservice.kioskgaming.cashier — đây mới là package id thật (không phải com.kioskgaming.cashier).
Service worker
registerServiceWorker() chạy ở web; trong WebrView remote nó hỗ trợ cache/PWA cơ bản, nhưng không bù được việc server.url cần mạng.
gradlew assembleDebug
Cả dev lẫn prod đều build debug APK (chưa thấy assembleRelease/ký số trong script).
Tailwind v3 & CRACO
Khác kiosk frontend (Tailwind v4 không config): đây là Tailwind v3 có tailwind.config.js + postcss.config.js, dùng design token kiểu hsl(var(--…)). Build qua CRACO để vá webpack.
const patch = require('../packages/ui/scripts/craco-patch-source-map-loader-ui.cjs'); module.exports = { webpack: { configure: (cfg) => { patch(cfg); return cfg; }, }, };
Token màu qua CSS var
tailwind.config.js map background, primary, accent… sang hsl(var(--…)). Content quét cả ../packages/ui/dist/**/*.js để giữ class của UI package.
Vì sao cần CRACO?
react-scripts không cho sửa webpack. CRACO chèn bản vá source-map-loader để packages/ui (dạng file:) không làm build vỡ vì thiếu source map.
Trang login dùng gradient hổ phách–emerald (from-amber-50 … to-emerald-50), nút chính màu amber-600; màn hình trong ca dùng tông stone trung tính + nút emerald-600 cho hành động tích cực và rose-600 cho đóng ca. Icon lấy từ lucide-react.
Config & biến môi trường
Tập trung ở src/config/env.ts. Theo chuẩn CRA, biến phải có tiền tố REACT_APP_ mới lọt vào bundle (khác Next.js dùng NEXT_PUBLIC_).
| Biến | Mặc định | Ý nghĩa |
|---|---|---|
REACT_APP_API_BASE_URL | http://localhost:3001/api | Base URL backend (đã gồm /api) |
REACT_APP_APP_NAME | Cashier — Kiosk Gaming | Tiêu đề tab (đặt ở index.tsx) |
REACT_APP_TURNSTILE_ENABLED | false | Bật Cloudflare Turnstile (=== 'true') |
REACT_APP_TURNSTILE_SITE_KEY | '' | Site key Turnstile |
CAP_SERVER_URL | https://cashier.kioskservice.club/ | URL WebView Android (lúc cap sync) |
Repo có cài @marsidev/react-turnstile và đọc TURNSTILE_* trong env.ts, nhưng CashierLoginPage hiện chưa render widget Turnstile. Nếu cần bật chống bot ở đăng nhập, đây là điểm phải nối thêm. (Dependency qrcode cũng có sẵn cho nhu cầu tương lai.)
Thiết lập kiểm thử
Repo có bộ @testing-library/* qua CRA/Jest nhưng hiện chưa có test file thực tế trong codebase.
Có gì
Chạy bằng craco test (Jest của CRA). setupTests.ts nạp @testing-library/jest-dom. Sẵn sàng viết test nhưng chưa có file *.test.tsx trong repo.
Khoảng trống
Chưa có test cho DenominationGrid, deposit/redeem, hay router guard. Phần lõi nghiệp vụ tiền hiện chưa được phủ test.
npm start # craco start → http://localhost:3000 npm run build # craco build → thư mục build/ npm test # craco test (Jest watch) npm run apk:prod # cap sync + gradlew assembleDebug
Cookbook cho dev mới
Các tác vụ phổ biến theo đúng quy ước của project.
- Viết hàm export trong
src/services/cashierApi.ts, dùngcashierClient.get/post(đã có token). - Path bắt đầu bằng
/cashier...— không thêm/api(đã nằm trongbaseURL). - Bọc
try/catch+asApiErrorPayload(e)để trả envelope{ success, data?, message? }. - Khai báo type response ngay trong service.
- Tạo
src/pages/XxxPage.tsx, bọc nội dung bằng<CashierLayout title=… backTo=…>. - Khai báo
<Route>trongApp.tsxvà bọc bằng<Protected>. - Lấy
idca bằnguseParams; nếu thiếu thì<Navigate to="/" />. - Hiển thị lỗi bằng
toast(react-hot-toast) thay vì alert.
const [denoms, setDenoms] = useState(emptyDenominationGrid()).- Render
<DenominationGrid value={denoms} onChange={setDenoms} />. - Trước khi gửi backend, lọc
denoms.filter(d => d.quantity > 0). - Hiển thị tổng bằng
computeDenominationTotal()/formatCurrency().
npm run build(CRA sinhbuild/— vẫn cần cho cap sync dù WebView dùng remote).npm run apk:devhoặcapk:prodđể setCAP_SERVER_URL+ sync +gradlew assembleDebug.- APK nằm trong
android/app/build/outputs/apk/debug/; cài lên thiết bị. - Đăng nhập lần đầu sẽ đăng ký thiết bị → nhờ quản lý cửa hàng duyệt thiết bị.
Bẫy thường gặp (đọc trước khi sửa)
| Bẫy | Chi tiết & cách xử lý |
|---|---|
| Tưởng app chạy offline | server.url trong capacitor.config.ts trỏ web live → mất mạng là trắng màn hình. webDir: 'build' bị ghi đè. |
Thêm /api hai lần | API_BASE_URL đã gồm /api. Path service phải bắt đầu bằng /cashier.... |
| Không có refresh token | Chỉ access token. 401 → đăng xuất ngay (event cashier-auth:unauthorized). Không retry. |
| Token không được verify lúc khởi động | Provider tin localStorage, không gọi /cashier/me. Token chết chỉ lộ ở request đầu. |
| Thiết bị chưa được duyệt | Đăng nhập OK nhưng giao dịch có thể bị chặn tới khi quản lý duyệt thiết bị ở Shop Portal. |
Quên lọc quantity > 0 | Mọi page đếm tiền lọc bỏ mục số lượng 0 trước khi gửi. Bỏ bước này có thể gây lỗi/dữ liệu rác. |
| Nhầm key localStorage | Có cả shop_* và cashier_*. Auth cashier hoàn toàn tách biệt Shop Admin. |
| Turnstile/qrcode cài nhưng chưa nối | Dependency tồn tại, chưa render. Đừng tưởng đăng nhập đã có chống bot. |
| appId thật khác báo cáo cũ | Là club.kioskservice.kioskgaming.cashier, không phải com.kioskgaming.cashier. |