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.
| Concern | Choice | Declared in |
|---|---|---|
| UI | React 18 | package.json |
| Build | Vite 6 | package.json |
| Routing | react-router-dom 7 | package.json |
| Client state | @reduxjs/toolkit 2.5 with redux-persist | package.json, src/redux/store.js |
| Server state | @tanstack/react-query 5 | package.json, src/configs/queryClient.js |
| HTTP | axios 1.7 behind one shared instance | src/https/axiosWrapper.js |
| Realtime | socket.io-client 4.8 | src/services/SocketService.js |
| Push | Firebase Cloud Messaging | src/firebase.js |
| Styling | Tailwind 3.4 plus CSS variables | src/index.css |
| Unit tests | vitest | package.json, vite.config.js |
| End to end tests | @playwright/test | package.json, playwright.config.js |
index.html mounts the app into #root and loads src/main.jsx.
Build and tooling
Scripts come from package.json.
| Script | Runs | Notes |
|---|---|---|
npm run dev | vite | Dev server on port 5173 |
npm run build | node scripts/generate-sw.js && vite build | Regenerates the service worker first |
npm run build:skip-sw | vite build | Build without the service worker step |
npm run preview | vite preview | Serves the built output |
npm run lint | eslint . | Config in eslint.config.js |
npm test | vitest --run | Unit tests, single run |
npm run test:watch | vitest | Unit tests, watch mode |
npm run test:e2e | playwright test | Config 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.pemandcerts/localhost+2.pemboth exist. /apiis proxied toprocess.env.VITE_API_PROXY_TARGET, defaulting tohttp://localhost:8000. This is why local development needs no frontend env file.- The proxy rewrites cookie domains back to
localhostand forwards every incoming header to the backend. - A
testblock configures Vitest withjsdom,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.
| Layer | Purpose |
|---|---|
BrowserRouter | History and route matching |
Redux Provider | Makes the store available, including to axiosWrapper |
PersistGate | Waits for the persisted Redux state to rehydrate |
ThemeProvider | Theme value and toggle, src/context/ThemeContext.jsx |
SnackbarProvider | notistack toasts |
QueryClientProvider | React Query, replaced by PersistQueryClientProvider in production builds |
AuthProvider | Session, login and logout, src/auth/AuthContext.jsx |
NotificationProvider, OrderAlertProvider, SocketProvider | In app notifications, order alert cards, socket lifecycle |
App | ErrorBoundary 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.
| Route | Guard | Notes |
|---|---|---|
/auth, /register | none, redirects to / when signed in | Auth page |
/auth/success | none | src/components/auth/AuthSuccess.jsx stores the token query parameter and then calls /api/auth/me |
/select-outlet | signed in | src/components/auth/OutletSelection.jsx |
/pending | signed in | PendingApproval |
/ | signed in | Home |
/orders, /orders/:id | menuKey="orders" | |
/tables | menuKey="tables" | |
/kitchen | menuKey="kitchen" | |
/menu | menuKey="menu" | |
/support, /design-system, /login, /setup | signed in | Open by design |
/admin/* | menuKey per group | See the admin groups below |
/debug-role | none | Developer page, outside the signed in group |
* | none | Renders 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 group | Guard |
|---|---|
| Categories, products, product types | menuKey="menu" |
| Inventory, invoices, stocktake | menuKey="inventory" |
| Sales dashboards and sales reports | menuKey="reports" |
| Tables management, table areas, tables | menuKey="tables" |
| Operation timing | menuKey="settings" |
| Staff master | menuKey="staff" |
| Accounting screens | menuKey="reports" action="accounting" |
| Customer due | menuKey="customers" action="view_history" |
| Live feeding, order archive, payment verification | menuKey="orders" |
| Overview and control center | menuKey="admin" |
| Cafe management | menuKey="outlets" |
| Franchise management | menuKey="franchise" |
| Reports | menuKey="reports" |
| Printer management | menuKey="printers" |
Two guard components and one provider do the work.
ProtectedRoutesinsrc/App.jsxreadsisAuthfrom theuserslice and redirects to/authwhen false.src/components/shared/ProtectedRoute.jsxreadsisMenuAllowedandcanfrom the permission context. A failed check logs a warning and redirects to/. It rendersFullScreenLoaderwhile permissions load.PermissionProviderinsrc/context/PermissionContext.jsxwraps 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_REACHEDlistener and aGLOBAL_API_ERRORlistener turn window events into toasts. onlineandofflinelisteners show banners and dispatchSOCKET_RECONNECTEDwhen the network returns.- A path effect clears the cart and customer slices whenever the user leaves the POS routes
/tablesand/menu. SidebarandHeaderare hidden on/auth,/select-outletand/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/
| Path | Holds |
|---|---|
src/main.jsx | Entry point and provider tree |
src/App.jsx | Layout, 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.
| Slice | File | Holds |
|---|---|---|
user | src/redux/slices/userSlice.js | user, 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. |
cart | src/redux/slices/cartSlice.js | An 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. |
customer | src/redux/slices/customerSlice.js | The order context for the order being taken, orderId, existingOrderId, paymentMethod, customer fields, guests, table, totalDue. |
outlet | src/redux/slices/outletSlice.js | currentOutletId, 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. |
organization | src/redux/slices/organizationSlice.js | The organization record and onboarding status, with thunks for fetch, onboarding status check, create and update. |
printer | src/redux/slices/printerSlice.js | isConnected, 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.
| Kind | Lives in | Examples |
|---|---|---|
| Server state | TanStack Query | Products, orders, tables, inventory, reports, billing config |
| Client or session state | Redux plus localforage | Cart, current order context, signed in user, current outlet, printer status |
React Query is configured in src/configs/queryClient.js.
| Setting | Development | Production |
|---|---|---|
staleTime | 0 | 24 hours |
gcTime | 5 minutes | 7 days |
refetchOnMount | true | false |
refetchOnWindowFocus | false | false |
refetchOnReconnect | false | false |
retry | 1 | 1 |
networkMode | offlineFirst | offlineFirst |
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.
| Piece | File | Role |
|---|---|---|
| Shared instance | src/https/axiosWrapper.js | Interceptors, headers, token refresh, error events |
| Endpoint functions | src/https/index.js | Named one line wrappers per backend route, imported by components |
| Safe wrappers | src/https/safeApiService.js | safeGet, safePost, safePut, safeDelete return a parsed result instead of throwing |
| Dashboard client | src/services/apiService.js | A second axios instance with its own interceptors, used by some older screens |
| Domain services | src/services/tableService.js, productService.js, inventoryService.js, reportsService.js | Call axiosWrapper, map the payload, and return safe defaults |
| Endpoint constants | src/configs/apiEndpoints.js | TENANT_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.
| Header | Value |
|---|---|
Authorization | Bearer token from the user slice, falling back to local storage |
x-panel-type | Always TENANT |
x-outlet-id | Resolved outlet, with Redux taking priority over local storage |
x-organization-id | Organization 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
401by posting to/api/auth/refreshwith the stored refresh token, then replays the original request. When the refresh fails it clears the tokens and the user slice. - It turns
402withcode: "PLAN_LIMIT_REACHED"into aPLAN_LIMIT_REACHEDwindow event. - It retries
GETrequests once after a503that mentions tenant initialization, and emitsTENANT_INITIALIZINGmeanwhile. - It emits
ACCESS_DENIEDfor403andGLOBAL_API_ERRORfor 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_PERMISSIONSfirst so the UI renders before the network answers. - Each entry in the map holds
visible,actionsandlabel. - String permissions such as
VIEW_ORDERSare derived from the actions object through a local table in the same file. - Owners, admins and super admins get the
adminmenu if the backend omits it. The same three roles short circuit every check inhasPermission,hasAnyPermission,hasAllPermissions,canandisMenuAllowed. can(menuKey, action)acceptsview,create,updateor any key in the menu actions object. It mapsupdatetomodifybefore readingactions.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.
| Layer | Mechanism |
|---|---|
| Selection | src/components/auth/OutletSelection.jsx dispatches setCurrentOutlet on click, and auto-selects when the user has exactly one outlet |
| Storage | The setCurrentOutlet reducer writes currentOutletId to state and ACTIVE_OUTLET_ID to local storage in the same call |
| Transport | The 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.
switchOutletposts to/api/tenant/outlets/switch, writes the outlet id returned by the server intoACTIVE_OUTLET_ID, stampsOUTLET_SWITCH_TIMESTAMP, and refetches the outlet list.switchFranchiseposts 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
NotificationContextandOrderAlertContext. - Toasts and browser notifications.
windowcustom 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:
npm test # vitest, single runnpm run test:watch # vitest, watch modenpx playwright install chromium # one timenpm run test:e2e -- --project=chromium # backend must be runningSources
brewlabs-billu-frontend/package.jsonbrewlabs-billu-frontend/vite.config.jsbrewlabs-billu-frontend/eslint.config.jsbrewlabs-billu-frontend/playwright.config.jsbrewlabs-billu-frontend/LOCAL_SETUP.mdbrewlabs-billu-frontend/.env.examplebrewlabs-billu-frontend/.github/workflows/ci.ymlbrewlabs-billu-frontend/index.htmlbrewlabs-billu-frontend/scripts/generate-sw.jsbrewlabs-billu-frontend/src/main.jsxbrewlabs-billu-frontend/src/App.jsxbrewlabs-billu-frontend/src/firebase.jsbrewlabs-billu-frontend/src/index.cssbrewlabs-billu-frontend/src/redux/store.jsbrewlabs-billu-frontend/src/redux/slices/userSlice.jsbrewlabs-billu-frontend/src/redux/slices/cartSlice.jsbrewlabs-billu-frontend/src/redux/slices/customerSlice.jsbrewlabs-billu-frontend/src/redux/slices/outletSlice.jsbrewlabs-billu-frontend/src/redux/slices/organizationSlice.jsbrewlabs-billu-frontend/src/redux/slices/printerSlice.jsbrewlabs-billu-frontend/src/https/axiosWrapper.jsbrewlabs-billu-frontend/src/https/index.jsbrewlabs-billu-frontend/src/https/safeApiService.jsbrewlabs-billu-frontend/src/services/apiService.jsbrewlabs-billu-frontend/src/services/tableService.jsbrewlabs-billu-frontend/src/services/SocketService.jsbrewlabs-billu-frontend/src/services/NotificationService.jsbrewlabs-billu-frontend/src/configs/queryClient.jsbrewlabs-billu-frontend/src/configs/apiEndpoints.jsbrewlabs-billu-frontend/src/config/menuActionsConfig.jsbrewlabs-billu-frontend/src/context/PermissionContext.jsxbrewlabs-billu-frontend/src/context/SocketContext.jsxbrewlabs-billu-frontend/src/constants/permissions.jsbrewlabs-billu-frontend/src/auth/AuthContext.jsxbrewlabs-billu-frontend/src/auth/authService.jsbrewlabs-billu-frontend/src/components/shared/ProtectedRoute.jsxbrewlabs-billu-frontend/src/components/shared/Sidebar.jsxbrewlabs-billu-frontend/src/components/shared/Header.jsxbrewlabs-billu-frontend/src/components/auth/AuthSuccess.jsxbrewlabs-billu-frontend/src/components/auth/RoleGuard.jsxbrewlabs-billu-frontend/src/components/auth/OutletSelection.jsxbrewlabs-billu-frontend/src/components/OutletSwitcher.jsxbrewlabs-billu-frontend/src/components/admin/AdminLayout.jsxbrewlabs-billu-frontend/src/context/ThemeContext.jsxbrewlabs-billu-frontend/src/pages/index.jsbrewlabs-billu-frontend/src/pages/Admin.jsxbrewlabs-billu-frontend/src/hooks/useLoadData.jsbrewlabs-billu-frontend/src/hooks/useProducts.jsbrewlabs-billu-frontend/src/hooks/useRealtimeAvailability.jsbrewlabs-billu-frontend/src/hooks/useSafeQueries.jsbrewlabs-billu-frontend/src/hooks/useFCMNotifications.jsbrewlabs-billu-frontend/src/hooks/useSocket.jsbrewlabs-billu-frontend/src/utils/outletManager.jsbrewlabs-billu-frontend/src/utils/index.jsbrewlabs-billu-frontend/src/utils/imageUpload.jsbrewlabs-billu-frontend/src/design-system/index.jsbrewlabs-billu-frontend/src/test/setup.js