Skip to content

Frontend overview

Source: g2labs-in/billuu-docs@9f1dcd7 · content/frontend/overview.md · updated 2026-09-16

What this app is

pos-frontend is the browser app the restaurant staff use. It serves the tenant panel only. src/App.jsx signs out any session that is not a tenant session, once because user.role is SUPER_ADMIN and again because user.panelType is not TENANT.

ConcernChoiceDeclared in
UIReact 18package.json
BuildVite 6package.json
Routingreact-router-dom 7package.json
Client state@reduxjs/toolkit 2.5 with redux-persistpackage.json, src/redux/store.js
Server state@tanstack/react-query 5package.json, src/configs/queryClient.js
HTTPaxios 1.7 behind one shared instancesrc/https/axiosWrapper.js
Realtimesocket.io-client 4.8src/services/SocketService.js
PushFirebase Cloud Messagingsrc/firebase.js
StylingTailwind 3.4 plus CSS variablessrc/index.css
Unit testsvitestpackage.json, vite.config.js
End to end tests@playwright/testpackage.json, playwright.config.js

index.html mounts the app into #root and loads src/main.jsx.

Build and tooling

Scripts come from package.json.

ScriptRunsNotes
npm run devviteDev server on port 5173
npm run buildnode scripts/generate-sw.js && vite buildRegenerates the service worker first
npm run build:skip-swvite buildBuild without the service worker step
npm run previewvite previewServes the built output
npm run linteslint .Config in eslint.config.js
npm testvitest --runUnit tests, single run
npm run test:watchvitestUnit tests, watch mode
npm run test:e2eplaywright testConfig in playwright.config.js

vite.config.js holds more than the plugin list.

  • The dev server binds on all interfaces and listens on port 5173.
  • HTTPS is enabled only when certs/localhost+2-key.pem and certs/localhost+2.pem both exist.
  • /api is proxied to process.env.VITE_API_PROXY_TARGET, defaulting to http://localhost:8000. This is why local development needs no frontend env file.
  • The proxy rewrites cookie domains back to localhost and forwards every incoming header to the backend.
  • A test block configures Vitest with jsdom, globals, src/test/setup.js, and an exclude list that keeps **/tests/** out of the unit run.

scripts/generate-sw.js reads the VITE_FIREBASE_* variables and writes public/firebase-messaging-sw.js. When those variables are absent it leaves the checked in file alone. ci.yml in .github/workflows/ runs npm ci, npm run build, then npm run lint.

Startup order

src/main.jsx renders the whole provider tree in one place. Order matters, so read it top down before moving a provider.

LayerPurpose
BrowserRouterHistory and route matching
Redux ProviderMakes the store available, including to axiosWrapper
PersistGateWaits for the persisted Redux state to rehydrate
ThemeProviderTheme value and toggle, src/context/ThemeContext.jsx
SnackbarProvidernotistack toasts
QueryClientProviderReact Query, replaced by PersistQueryClientProvider in production builds
AuthProviderSession, login and logout, src/auth/AuthContext.jsx
NotificationProvider, OrderAlertProvider, SocketProviderIn app notifications, order alert cards, socket lifecycle
AppErrorBoundary wrapping PermissionProvider wrapping Layout

Two environment switches live here. Development builds use the plain QueryClientProvider so the persisted query cache is skipped. Production builds silence console.log and console.info.

Routing and guards

All routes live in src/App.jsx inside the Layout component. There is no separate routes file.

Route components are split two ways. Some are imported eagerly, for example Home and Admin through src/pages/index.js. Most are declared with React.lazy at the top of src/App.jsx and rendered inside Suspense with FullScreenLoader as the fallback.

RouteGuardNotes
/auth, /registernone, redirects to / when signed inAuth page
/auth/successnonesrc/components/auth/AuthSuccess.jsx stores the token query parameter and then calls /api/auth/me
/select-outletsigned insrc/components/auth/OutletSelection.jsx
/pendingsigned inPendingApproval
/signed inHome
/orders, /orders/:idmenuKey="orders"
/tablesmenuKey="tables"
/kitchenmenuKey="kitchen"
/menumenuKey="menu"
/support, /design-system, /login, /setupsigned inOpen by design
/admin/*menuKey per groupSee the admin groups below
/debug-rolenoneDeveloper page, outside the signed in group
*noneRenders the text Not Found

The admin tree nests under /admin, which renders src/pages/Admin.jsx, which renders src/components/admin/AdminLayout.jsx. Each child group carries its own guard.

Admin groupGuard
Categories, products, product typesmenuKey="menu"
Inventory, invoices, stocktakemenuKey="inventory"
Sales dashboards and sales reportsmenuKey="reports"
Tables management, table areas, tablesmenuKey="tables"
Operation timingmenuKey="settings"
Staff mastermenuKey="staff"
Accounting screensmenuKey="reports" action="accounting"
Customer duemenuKey="customers" action="view_history"
Live feeding, order archive, payment verificationmenuKey="orders"
Overview and control centermenuKey="admin"
Cafe managementmenuKey="outlets"
Franchise managementmenuKey="franchise"
ReportsmenuKey="reports"
Printer managementmenuKey="printers"

Two guard components and one provider do the work.

  • ProtectedRoutes in src/App.jsx reads isAuth from the user slice and redirects to /auth when false.
  • src/components/shared/ProtectedRoute.jsx reads isMenuAllowed and can from the permission context. A failed check logs a warning and redirects to /. It renders FullScreenLoader while permissions load.
  • PermissionProvider in src/context/PermissionContext.jsx wraps everything so those checks have data.

src/components/auth/RoleGuard.jsx exists and checks a hardcoded allowedRoles list. Nothing imports it, so it is not part of the live route tree.

Layout also runs side effects that belong to the shell rather than to any route.

  • useRealtimeAvailability() mounts once for the whole app.
  • useLoadData() refreshes the signed in user in the background and reports a loading flag.
  • A PLAN_LIMIT_REACHED listener and a GLOBAL_API_ERROR listener turn window events into toasts.
  • online and offline listeners show banners and dispatch SOCKET_RECONNECTED when the network returns.
  • A path effect clears the cart and customer slices whenever the user leaves the POS routes /tables and /menu.
  • Sidebar and Header are hidden on /auth, /select-outlet and /pending, and on any path starting with /admin, where the admin layout supplies its own chrome.
  • useFCMNotifications(isAuth) registers the push token.

Folder layout under src/

PathHolds
src/main.jsxEntry point and provider tree
src/App.jsxLayout, routes, guards, and the shell side effects
src/pages/One file per route, plus src/pages/admin/ for the admin screens and src/pages/__tests__/
src/components/Feature folders, for example menu/, tables/, kitchen/, orders/, auth/, admin/, shared/, notifications/, printing/
src/design-system/Shared primitives and layout components re-exported from src/design-system/index.js
src/context/React contexts, PermissionContext.jsx, SocketContext.jsx, NotificationContext.jsx, OrderAlertContext.jsx, ThemeContext.jsx
src/redux/store.js and slices/
src/https/The HTTP layer, axiosWrapper.js, index.js, safeApiService.js
src/services/Domain services and singletons, for example tableService.js, SocketService.js, ReceiptPrintService.js, NotificationService.js
src/hooks/Reusable hooks, for example useProducts.js, useLoadData.js, useRealtimeAvailability.js, useFCMNotifications.js
src/utils/Mappers, safe accessors, formatting, outletManager.js, re-exported from src/utils/index.js
src/constants/permissions.js with roles, permission strings and role mappings
src/configs/apiEndpoints.js and queryClient.js
src/config/menuActionsConfig.js, the menu registry
src/core/printer/ESC/POS printing, adapters per transport, printer manager
src/auth/AuthContext.jsx, authService.js, login screens
src/test/Vitest setup file

archive/legacy_src/, scratch/ and src/pages/archive/ hold retired code. eslint.config.js ignores all three.

Redux store

src/redux/store.js combines six slices and persists all of them.

const persistConfig = {
key: 'root',
version: 1,
storage: localforage,
whitelist: ['user', 'cart', 'organization', 'outlet', 'customer', 'printer']
};

Persistence uses localforage, so the state survives a reload in IndexedDB. The serializable check ignores the redux-persist lifecycle actions.

SliceFileHolds
usersrc/redux/slices/userSlice.jsuser, token, isAuth, business, organization, selectedOutlet, menu, permissions, tenantStatus, trialStatus. setUser also auto-selects the outlet when the login payload contains exactly one. removeUser clears ACTIVE_OUTLET_ID from local storage.
cartsrc/redux/slices/cartSlice.jsAn array of line items. Reducers add, remove, set quantity, set a note, load an existing order, and flag unavailable items. getTotalPrice derives the rounded total.
customersrc/redux/slices/customerSlice.jsThe order context for the order being taken, orderId, existingOrderId, paymentMethod, customer fields, guests, table, totalDue.
outletsrc/redux/slices/outletSlice.jscurrentOutletId, currentOutlet, outlets, role and permission fields for the current outlet, outletUsers, plus isSwitching and error fields. Async thunks cover fetchMyOutlets, fetchOrganizationOutlets, switchOutlet, switchFranchise, createOutlet, updateOutlet, deleteOutlet, assignUserToOutlet, removeUserFromOutlet and fetchOutletUsers.
organizationsrc/redux/slices/organizationSlice.jsThe organization record and onboarding status, with thunks for fetch, onboarding status check, create and update.
printersrc/redux/slices/printerSlice.jsisConnected, isScanning, connectedPrinters, pendingReconnectPrinters, lastPrinterId, error.

Only the outlet and organization slices own thunks that call the API. The rest are plain reducers driven from components.

Server state versus client state

The split is deliberate.

KindLives inExamples
Server stateTanStack QueryProducts, orders, tables, inventory, reports, billing config
Client or session stateRedux plus localforageCart, current order context, signed in user, current outlet, printer status

React Query is configured in src/configs/queryClient.js.

SettingDevelopmentProduction
staleTime024 hours
gcTime5 minutes7 days
refetchOnMounttruefalse
refetchOnWindowFocusfalsefalse
refetchOnReconnectfalsefalse
retry11
networkModeofflineFirstofflineFirst

Production also persists the cache. queryPersister writes to a localforage store named BilluPOS under the key REACT_QUERY_OFFLINE_CACHE. CACHE_VERSION in the same file is the lever for invalidating every persisted cache at once. patchQueryDataByPrefix in the same file updates every query whose key starts with a prefix, which avoids a refetch storm.

The query key convention includes the active outlet id.

queryKey: ["products", selectedOutlet?.id, categoryId || "all"]

src/hooks/useProducts.js is the reference implementation, and src/hooks/useRealtimeAvailability.js patches ['products', outletId] and invalidates ['inventory', outletId] using the same shape.

Two local storage keys back the client side caches. ACTIVE_OUTLET_ID is the persisted outlet, and OFFLINE_PERMISSIONS is the cached permission map written by src/context/PermissionContext.jsx.

The API layer

There is one shared axios instance, axiosWrapper in src/https/axiosWrapper.js. Everything else builds on it.

PieceFileRole
Shared instancesrc/https/axiosWrapper.jsInterceptors, headers, token refresh, error events
Endpoint functionssrc/https/index.jsNamed one line wrappers per backend route, imported by components
Safe wrapperssrc/https/safeApiService.jssafeGet, safePost, safePut, safeDelete return a parsed result instead of throwing
Dashboard clientsrc/services/apiService.jsA second axios instance with its own interceptors, used by some older screens
Domain servicessrc/services/tableService.js, productService.js, inventoryService.js, reportsService.jsCall axiosWrapper, map the payload, and return safe defaults
Endpoint constantssrc/configs/apiEndpoints.jsTENANT_ENDPOINTS and AUTH_ENDPOINTS string maps

The base URL comes from VITE_API_BASE_URL. A trailing /api is stripped, because every endpoint constant already includes the /api prefix. Requests are sent with credentials.

The request interceptor adds headers.

HeaderValue
AuthorizationBearer token from the user slice, falling back to local storage
x-panel-typeAlways TENANT
x-outlet-idResolved outlet, with Redux taking priority over local storage
x-organization-idOrganization id from the user slice or ACTIVE_ORG_ID

It also refuses tenant requests with no outlet. Outside the exempt list, a missing outlet logs a detailed error, dispatches outlet/setSwitchError, and rejects with MISSING_OUTLET_CONTEXT. The exempt list is '/api/auth/', '/api/onboarding/', '/api/public/', '/health' and '/api/super-admin/'. A super admin user is exempt as well, even though this panel rejects super admin sessions elsewhere.

The response interceptor does five things.

  • It unwraps the double wrapped payloads the backend produces, so { success, data: { success, data } } reaches callers as the inner object.
  • It retries once after a 401 by posting to /api/auth/refresh with the stored refresh token, then replays the original request. When the refresh fails it clears the tokens and the user slice.
  • It turns 402 with code: "PLAN_LIMIT_REACHED" into a PLAN_LIMIT_REACHED window event.
  • It retries GET requests once after a 503 that mentions tenant initialization, and emits TENANT_INITIALIZING meanwhile.
  • It emits ACCESS_DENIED for 403 and GLOBAL_API_ERROR for other failures, and retries any network error once after a one second delay.

Two debug switches live in the interceptor. Setting localStorage.apiTrace to '1' turns on a per request timing and caller stack trace. inflightRequests warns in the console when the same GET is issued while an identical one is still running.

Permissions

src/context/PermissionContext.jsx is the authority for what the UI shows. It fetches /api/me/menu-permissions through apiService, then builds a map keyed by menu.screen.

  • Cached permissions load from OFFLINE_PERMISSIONS first so the UI renders before the network answers.
  • Each entry in the map holds visible, actions and label.
  • String permissions such as VIEW_ORDERS are derived from the actions object through a local table in the same file.
  • Owners, admins and super admins get the admin menu if the backend omits it. The same three roles short circuit every check in hasPermission, hasAnyPermission, hasAllPermissions, can and isMenuAllowed.
  • can(menuKey, action) accepts view, create, update or any key in the menu actions object. It maps update to modify before reading actions.
  • isMenuAllowed('support') is always true.
  • The failure path sets an empty map. The UI then shows nothing permission gated rather than guessing.

The menu registry is src/config/menuActionsConfig.js. ALL_MENUS lists every menu key with its label, route, icon and allowed actions. MENU_GROUPS controls the sidebar grouping. src/components/shared/Sidebar.jsx filters MENU_GROUPS through isMenuAllowed and hides the rest.

src/constants/permissions.js is the static side of the same system. It exports ROLES, PERMISSIONS, ROLE_PERMISSIONS, MENU_PERMISSIONS, ACTION_PERMISSIONS, ROLE_HIERARCHY, normalizeRole, getPermissionsForRole and the role comparison helpers. src/redux/slices/outletSlice.js falls back to getPermissionsForRole only when neither outlet permissions nor user permissions exist.

How the app knows which outlet it operates on

Every tenant request needs an outlet. Three layers cooperate.

LayerMechanism
Selectionsrc/components/auth/OutletSelection.jsx dispatches setCurrentOutlet on click, and auto-selects when the user has exactly one outlet
StorageThe setCurrentOutlet reducer writes currentOutletId to state and ACTIVE_OUTLET_ID to local storage in the same call
TransportThe axiosWrapper request interceptor reads Redux first and local storage second, then sets x-outlet-id

src/utils/outletManager.js is the small helper for the same key, with getOutletId, setOutletId and handleSwitchOutlet.

Switching has two paths, both in src/redux/slices/outletSlice.js.

  • switchOutlet posts to /api/tenant/outlets/switch, writes the outlet id returned by the server into ACTIVE_OUTLET_ID, stamps OUTLET_SWITCH_TIMESTAMP, and refetches the outlet list.
  • switchFranchise posts to /api/tenant/switch, replaces the access and refresh tokens, writes the new outlet id, then refetches.

src/auth/AuthContext.jsx restores the saved outlet after a session check and keeps ACTIVE_OUTLET_ID in step with the Redux slice. src/components/shared/Header.jsx also writes the header default when the user switches, alongside a query cache clear.

The outlet id is not only a header. SocketContext connects the socket with the current outlet id (see below), and every outlet scoped React Query key includes it.

Realtime

src/services/SocketService.js exports one socket singleton. connect(outletId) disconnects any existing socket for a different outlet, opens a new one, and reconnects on demand.

src/context/SocketContext.jsx owns the lifecycle. It connects 1.5 seconds after mount when a user is signed in and an outlet is selected, and disconnects on sign out or outlet removal. It subscribes to ORDER_CREATED, ORDER_UPDATED, ORDER_SETTLED, INVENTORY_UPDATED, KOT_CREATED, KOT_SENT, TABLE_UPDATED, TABLE_TRANSFERRED, PRODUCT_AVAILABILITY_UPDATED, PRODUCT_OUT_OF_STOCK and PRODUCT_RESTORED.

Handlers fan out to three destinations.

  • Notification state through NotificationContext and OrderAlertContext.
  • Toasts and browser notifications.
  • window custom events, which decouple sockets from screens.

src/hooks/useRealtimeAvailability.js listens for the availability events and edits the React Query cache in place instead of refetching. It also reconciles after a socket reconnect.

Push notifications are separate. src/firebase.js initializes Firebase only when the host is not localhost. src/hooks/useFCMNotifications.js registers the device token through src/services/NotificationService.js, which calls the backend with axiosWrapper.

Running and testing it locally

LOCAL_SETUP.md in this repo is short and points at the backend repo for the full environment. The local loop is nvm use 20, npm ci, npm run dev. The Vite proxy sends /api to localhost:8000, so no frontend env file is needed.

Tests:

Terminal window
npm test # vitest, single run
npm run test:watch # vitest, watch mode
npx playwright install chromium # one time
npm run test:e2e -- --project=chromium # backend must be running

Sources

  • brewlabs-billu-frontend/package.json
  • brewlabs-billu-frontend/vite.config.js
  • brewlabs-billu-frontend/eslint.config.js
  • brewlabs-billu-frontend/playwright.config.js
  • brewlabs-billu-frontend/LOCAL_SETUP.md
  • brewlabs-billu-frontend/.env.example
  • brewlabs-billu-frontend/.github/workflows/ci.yml
  • brewlabs-billu-frontend/index.html
  • brewlabs-billu-frontend/scripts/generate-sw.js
  • brewlabs-billu-frontend/src/main.jsx
  • brewlabs-billu-frontend/src/App.jsx
  • brewlabs-billu-frontend/src/firebase.js
  • brewlabs-billu-frontend/src/index.css
  • brewlabs-billu-frontend/src/redux/store.js
  • brewlabs-billu-frontend/src/redux/slices/userSlice.js
  • brewlabs-billu-frontend/src/redux/slices/cartSlice.js
  • brewlabs-billu-frontend/src/redux/slices/customerSlice.js
  • brewlabs-billu-frontend/src/redux/slices/outletSlice.js
  • brewlabs-billu-frontend/src/redux/slices/organizationSlice.js
  • brewlabs-billu-frontend/src/redux/slices/printerSlice.js
  • brewlabs-billu-frontend/src/https/axiosWrapper.js
  • brewlabs-billu-frontend/src/https/index.js
  • brewlabs-billu-frontend/src/https/safeApiService.js
  • brewlabs-billu-frontend/src/services/apiService.js
  • brewlabs-billu-frontend/src/services/tableService.js
  • brewlabs-billu-frontend/src/services/SocketService.js
  • brewlabs-billu-frontend/src/services/NotificationService.js
  • brewlabs-billu-frontend/src/configs/queryClient.js
  • brewlabs-billu-frontend/src/configs/apiEndpoints.js
  • brewlabs-billu-frontend/src/config/menuActionsConfig.js
  • brewlabs-billu-frontend/src/context/PermissionContext.jsx
  • brewlabs-billu-frontend/src/context/SocketContext.jsx
  • brewlabs-billu-frontend/src/constants/permissions.js
  • brewlabs-billu-frontend/src/auth/AuthContext.jsx
  • brewlabs-billu-frontend/src/auth/authService.js
  • brewlabs-billu-frontend/src/components/shared/ProtectedRoute.jsx
  • brewlabs-billu-frontend/src/components/shared/Sidebar.jsx
  • brewlabs-billu-frontend/src/components/shared/Header.jsx
  • brewlabs-billu-frontend/src/components/auth/AuthSuccess.jsx
  • brewlabs-billu-frontend/src/components/auth/RoleGuard.jsx
  • brewlabs-billu-frontend/src/components/auth/OutletSelection.jsx
  • brewlabs-billu-frontend/src/components/OutletSwitcher.jsx
  • brewlabs-billu-frontend/src/components/admin/AdminLayout.jsx
  • brewlabs-billu-frontend/src/context/ThemeContext.jsx
  • brewlabs-billu-frontend/src/pages/index.js
  • brewlabs-billu-frontend/src/pages/Admin.jsx
  • brewlabs-billu-frontend/src/hooks/useLoadData.js
  • brewlabs-billu-frontend/src/hooks/useProducts.js
  • brewlabs-billu-frontend/src/hooks/useRealtimeAvailability.js
  • brewlabs-billu-frontend/src/hooks/useSafeQueries.js
  • brewlabs-billu-frontend/src/hooks/useFCMNotifications.js
  • brewlabs-billu-frontend/src/hooks/useSocket.js
  • brewlabs-billu-frontend/src/utils/outletManager.js
  • brewlabs-billu-frontend/src/utils/index.js
  • brewlabs-billu-frontend/src/utils/imageUpload.js
  • brewlabs-billu-frontend/src/design-system/index.js
  • brewlabs-billu-frontend/src/test/setup.js