Frontend conventions
Source:
g2labs-in/billuu-docs@9f1dcd7·content/frontend/conventions.md· updated 2026-09-16
How to use this page
Each rule below is followed by the file that demonstrates it. Read that file before you copy the pattern. Where the codebase already breaks a rule, the exception is listed so you do not copy the wrong file.
HTTP and the API layer
Every request goes through axiosWrapper
Import axiosWrapper from src/https/axiosWrapper.js, or a named endpoint function from src/https/index.js, which wraps it. Never call axios.create in new code.
axiosWrapper is the only place that attaches the auth token, the panel header and the outlet header. A second instance silently loses all three.
Demonstrated by src/services/tableService.js, which imports axiosWrapper and calls it directly, and by src/https/index.js, which exports one named function per route.
Existing exceptions you should not copy.
| File | What it does | Why |
|---|---|---|
src/services/apiService.js | Creates a second instance with its own copy of the interceptors | Older screens still import it, for example src/components/shared/Header.jsx, src/redux/slices/outletSlice.js, src/redux/slices/organizationSlice.js and src/context/PermissionContext.jsx |
src/auth/authService.js | Creates a refreshInstance next to its axiosWrapper calls | The comment in the file explains it, a refresh call from inside the refresh interceptor would loop |
src/utils/imageUpload.js | Posts multipart uploads with raw axios | No interceptors means no automatic outlet header, so prefer axiosWrapper for any new upload |
Know which headers axiosWrapper injects
| Header | Source | Notes |
|---|---|---|
Authorization | state.user.token, then localStorage.accessToken | Present only when a token exists |
x-panel-type | Constant | Always TENANT |
x-outlet-id | Redux outlet.currentOutletId, then outlet.currentOutlet.id, then localStorage.ACTIVE_OUTLET_ID | Required for every non exempt tenant route |
x-organization-id | state.user.organization.id, state.user.organizationId, then localStorage.ACTIVE_ORG_ID | Present when an organization is known |
Demonstrated by the request interceptor in src/https/axiosWrapper.js.
Remember the exempt routes
A request outside the exempt list is rejected with MISSING_OUTLET_CONTEXT when no outlet is selected. The exempt list is defined in src/https/axiosWrapper.js.
/api/auth//api/onboarding//api/public//health/api/super-admin/
A user whose role is SUPER_ADMIN is exempt too. This panel still refuses super admin sessions at the layout level, so treat that branch as unused here.
Demonstrated by the EXEMPT_ROUTES array in src/https/axiosWrapper.js.
Outlet context
Read the outlet from Redux, not from local storage
state.outlet.currentOutletId is the in memory source of truth. localStorage.ACTIVE_OUTLET_ID is the persisted copy.
const { currentOutlet: selectedOutlet } = useSelector((state) => state.outlet);Demonstrated by src/hooks/useProducts.js and by every admin screen, for example src/pages/Orders.jsx.
Change the outlet through setCurrentOutlet
The setCurrentOutlet reducer writes Redux and ACTIVE_OUTLET_ID in one call. It accepts either an id string or a full outlet object.
dispatch(setCurrentOutlet(outlet.id));Demonstrated by src/components/auth/OutletSelection.jsx.
Precedence when the two stores disagree, from the request interceptor in src/https/axiosWrapper.js.
| Order | Source | Used when |
|---|---|---|
| 1 | Redux currentOutletId or currentOutlet.id | Always preferred |
| 2 | localStorage.ACTIVE_OUTLET_ID | Redux has no outlet yet, for example right after reload |
Use the thunks for switching
Do not hand roll a switch request. switchOutlet posts to /api/tenant/outlets/switch, persists the authoritative outlet id returned by the server, stamps OUTLET_SWITCH_TIMESTAMP, and refetches the outlet list. switchFranchise posts to /api/tenant/switch, replaces both tokens, then does the same.
Demonstrated by src/redux/slices/outletSlice.js and its consumer src/components/OutletSwitcher.jsx.
Scope every React Query key by outlet
An outlet scoped query key contains the outlet id. Without it, switching outlet shows the previous outlet data from cache.
queryKey: ["products", selectedOutlet?.id, categoryId || "all"]Demonstrated by src/hooks/useProducts.js and by src/hooks/useRealtimeAvailability.js, which patches ['products', outletId] and invalidates ['inventory', outletId] using the same shape.
Permissions
Never hardcode a role check
Role strings belong to the backend. The UI asks the permission system.
const { isMenuAllowed, can } = usePermission();| Call | Answers |
|---|---|
isMenuAllowed(menuKey) | Should this menu or route be reachable |
can(menuKey, action) | Should this action be reachable, with view, create, update and menu specific action keys |
Demonstrated by src/components/shared/ProtectedRoute.jsx for routes and by src/components/shared/Sidebar.jsx for navigation.
update is mapped to modify inside can, so both spellings work. isMenuAllowed('support') is always true.
Existing exceptions you should not copy.
| File | What it does |
|---|---|
src/components/auth/RoleGuard.jsx | Compares user.role against an allowedRoles prop. Nothing imports it, it is not in the route tree |
src/components/auth/OutletSelection.jsx | Compares user.role strings to decide whether to show outlet onboarding |
src/context/PermissionContext.jsx | Short circuits every check for OWNER, ADMIN and SUPER_ADMIN as a safety net, which is the documented owner bypass |
Understand where permissions come from
src/context/PermissionContext.jsx fetches /api/me/menu-permissions and builds a map keyed by the screen field.
| Field | Meaning |
|---|---|
menu.screen or menu.key | The menu key used by ALL_MENUS |
menu.visible or menu.allowed | Drives isMenuAllowed |
menu.actions | Object of action flags read by can |
menu.label | Display label |
The same file derives string permissions such as VIEW_ORDERS from the actions object and writes both to localStorage.OFFLINE_PERMISSIONS so the UI renders before the network answers. A failed fetch sets an empty map, which hides permission gated UI instead of guessing.
Use modify for menu permissions and update for the menu registry
Two vocabularies coexist and they are easy to mix up.
| Where the name appears | Action name for a change |
|---|---|
The /api/me/menu-permissions payload, and any call to can(menuKey, action) | modify |
The actions arrays in src/config/menuActionsConfig.js | update |
can translates before it reads the map.
const actionKey = action === 'update' ? 'modify' : action;So can('orders', 'update') reads actions.modify from the payload, while isValidAction('orders', 'update') matches the local array. Use modify when you are writing a permission check or describing what the backend returns, and update when you are editing a menu definition.
Demonstrated by src/context/PermissionContext.jsx and src/config/menuActionsConfig.js.
Route guards pass backend action keys directly, for example action="accounting" and action="view_history" in src/App.jsx. A custom key needs a matching entry in the payload’s actions object.
Use src/components/shared/Sidebar.jsx for navigation
src/components/sidebar/PermissionSidebar.jsx is dead and broken. It reads permissions from usePermission(), a key the context value does not export, so it would render an empty navigation if it were mounted. Nothing imports it. The live sidebar is src/components/shared/Sidebar.jsx.
Add a permission end to end
- Make the backend return the menu in
/api/me/menu-permissionswith ascreenkey, avisibleflag and anactionsobject.src/context/PermissionContext.jsxreads exactly those fields. - Add the permission strings to
PERMISSIONSinsrc/constants/permissions.js. - Add those strings to the relevant role lists in
ROLE_PERMISSIONSin the same file.src/redux/slices/outletSlice.jsuses those lists as the last fallback when neither outlet permissions nor user permissions exist. - Register the menu key in
ALL_MENUSinsrc/config/menuActionsConfig.js, withkey,label,route,iconand theactionsarray. Add the key toMENU_GROUPSwhen it should appear in the sidebar. - Add the key to the
actionPermstable insidefetchPermissionsinsrc/context/PermissionContext.jsxwhen the new actions should also produce string permissions forhasPermission. Skip this step when the feature only needs menu level checks. - Gate the route in
src/App.jsxwithProtectedRoute, usingmenuKeyand optionallyaction.
<Route element={<ProtectedRoute menuKey="inventory" />}> <Route path="inventory" element={<InventoryDashboard />} /></Route>- Gate buttons and menu items inside the screen with
can(menuKey, action). - Prove the plumbing with a unit test next to the API function, following
src/https/__tests__/index.test.js.
Routes and screens
Gate route groups, not single screens
Wrap a group of routes in one ProtectedRoute element and let child routes inherit it. Users without the permission are sent to / after a console warning.
Demonstrated by the nested admin routes in src/App.jsx.
Lazy load route components
Declare screens with React.lazy and render them inside Suspense with FullScreenLoader as the fallback. Home and Admin are the eager exceptions.
Demonstrated by the const Orders = lazy(...) block at the top of src/App.jsx.
Keep the POS session clean
The cart and customer slices describe one order. They are cleared when the user leaves /tables and /menu.
Demonstrated by the path effect in Layout in src/App.jsx.
Server state
Query for reads, thunks for session changes
Server data belongs in TanStack Query. Redux holds the POS session and the signed in user. The split is visible in src/redux/slices/outletSlice.js, which owns thunks because switching outlet changes the session, against src/hooks/useProducts.js, which owns a query because products are server data.
Do not change global query defaults per screen
src/configs/queryClient.js sets staleTime, gcTime, refetchOnWindowFocus, refetchOnReconnect, retry and networkMode: 'offlineFirst'. Override a setting on a single query only when you have a reason, the way src/hooks/useProducts.js sets retry: 2.
Update the cache instead of refetching everything
Two tools exist in src/configs/queryClient.js.
| Tool | Use |
|---|---|
patchQueryDataByPrefix(client, prefix, updater) | Rewrite cached data in place across every query with that key prefix |
queryClient.invalidateQueries({ queryKey }) | Mark queries stale when the server response shape changes |
Demonstrated by src/hooks/useRealtimeAvailability.js, which patches products in place on an availability event, and invalidates inventory instead.
Bump the cache version when a persisted shape changes
Production persists the React Query cache to IndexedDB. Changing the shape of cached data without bumping CACHE_VERSION ships stale objects to returning users.
Demonstrated by CACHE_VERSION in src/configs/queryClient.js.
Rely on the socket for freshness
refetchOnReconnect and refetchOnWindowFocus are off on purpose. Realtime updates arrive over the socket and land in the cache through src/hooks/useRealtimeAvailability.js.
Data handling
Treat every response as unknown
Backend payloads arrive wrapped, null, or with snake case keys. Use the safe accessors in src/utils/safeDataUtils.js and the mappers in src/utils/apiDataMappers.js instead of reaching into the payload.
const tablesData = getSafeArray(parsed.data, []);return safeMapArray(mapTable, tablesData, []);Demonstrated by src/services/tableService.js. The header comment in src/utils/apiDataMappers.js states the same rule directly, never access raw API JSON inside a component.
Prefer the domain services for mapped data
src/services/tableService.js, src/services/productService.js, src/services/inventoryService.js and src/services/reportsService.js call axiosWrapper, run the response through parseApiResponse, map the fields, and return an empty array or null on error. Screens that need mapped data should import these rather than calling the endpoint function and mapping by hand.
Normalize orders before rendering
Order payloads arrive in several shapes. src/utils/orderNormalization.js and src/utils/mappers.js exist for that job.
Styling and layout
Use the design tokens
src/index.css defines the theme as CSS variables, for example --bg-primary, --text-primary and --border-primary. Screens reference them through Tailwind arbitrary values such as bg-[var(--bg-primary)], and keep layout in ordinary Tailwind utilities.
Demonstrated by src/components/admin/AdminLayout.jsx, which composes Tailwind classes with the variable based colors.
Reuse the design system entry point
Shared primitives, domain components and layout pieces are re-exported from src/design-system/index.js. Import from there rather than reaching into a nested file.
Repo hygiene and generated files
Do not edit the generated service worker
scripts/generate-sw.js rewrites public/firebase-messaging-sw.js during npm run build whenever the VITE_FIREBASE_* variables are present. Change the generator, not the output.
Never commit env files
.env.example lists every variable the app reads. LOCAL_SETUP.md states the rule plainly. Local development does not need a frontend env file because the Vite proxy forwards /api to port 8000.
Demonstrated by vite.config.js and .env.example.
Leave the archive folders alone
archive/legacy_src/, scratch/ and src/pages/archive/ hold retired code and are excluded from lint. Do not extend them and do not import from them.
Demonstrated by the ignores list in eslint.config.js.
Follow the lint rules as written
eslint.config.js turns off react/prop-types because the project does not depend on PropTypes. Unused variables are errors unless the name starts with an underscore.
Tests
Unit tests live next to the code
Vitest picks up files under src/**/__tests__/. vite.config.js excludes **/tests/** from the Vitest run so Playwright specs are not collected twice. Setup comes from src/test/setup.js.
Mock the HTTP layer, not the network:
vi.mock('../axiosWrapper', () => ({ axiosWrapper: { post: mockPost }}));Demonstrated by src/https/__tests__/index.test.js.
End to end tests live under tests/e2e
Specs are tests/e2e/auth.spec.js, tests/e2e/kitchen.spec.js and tests/e2e/orders.spec.js. Shared steps live in tests/e2e/helpers.js, which logs in with the demo tenant account and waits for a POST /api/tenant/orders response.
Demonstrated by tests/e2e/helpers.js.
Playwright is installed
@playwright/test is in devDependencies in package.json at version ^1.63.0. The config file playwright.config.js exists at the repo root, and npm run test:e2e runs playwright test.
An older note claims the dependency is missing. That claim is wrong. The browser binaries are the part that needs a one time install.
npx playwright install chromiumplaywright.config.js starts the frontend itself with npm run dev and reuses an already running server outside CI. The backend still has to be up on port 8000 for a login to succeed.
Commands that work
Run these from the brewlabs-billu-frontend directory.
| Command | What it does | Needs the backend |
|---|---|---|
npm test | Vitest, single run | No |
npm run test:watch | Vitest in watch mode | No |
npm run test:e2e -- --project=chromium | Playwright, chromium project only | Yes |
npm run test:e2e | Playwright, every project in playwright.config.js | Yes |
npm run lint | eslint . | No |
npm run build | Regenerates the service worker, then builds | No |
Review checklist
Before you open a pull request, confirm each line.
- New requests use
axiosWrapperor a named function fromsrc/https/index.js. - New query keys include the outlet id when the data is outlet scoped.
- New route groups carry a
ProtectedRouteguard. - New buttons and menu entries ask
usePermissioninstead of comparing roles. - New cached data shapes come with a
CACHE_VERSIONbump. - New unit tests mock
axiosWrapperand sit in a__tests__folder next to the code.
Sources
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/scripts/generate-sw.jsbrewlabs-billu-frontend/tests/e2e/helpers.jsbrewlabs-billu-frontend/tests/e2e/orders.spec.jsbrewlabs-billu-frontend/tests/e2e/auth.spec.jsbrewlabs-billu-frontend/tests/e2e/kitchen.spec.jsbrewlabs-billu-frontend/src/App.jsxbrewlabs-billu-frontend/src/index.cssbrewlabs-billu-frontend/src/https/axiosWrapper.jsbrewlabs-billu-frontend/src/https/index.jsbrewlabs-billu-frontend/src/https/safeApiService.jsbrewlabs-billu-frontend/src/https/__tests__/index.test.jsbrewlabs-billu-frontend/src/services/apiService.jsbrewlabs-billu-frontend/src/services/tableService.jsbrewlabs-billu-frontend/src/services/productService.jsbrewlabs-billu-frontend/src/services/inventoryService.jsbrewlabs-billu-frontend/src/services/reportsService.jsbrewlabs-billu-frontend/src/auth/authService.jsbrewlabs-billu-frontend/src/redux/slices/outletSlice.jsbrewlabs-billu-frontend/src/redux/slices/organizationSlice.jsbrewlabs-billu-frontend/src/redux/slices/userSlice.jsbrewlabs-billu-frontend/src/configs/queryClient.jsbrewlabs-billu-frontend/src/config/menuActionsConfig.jsbrewlabs-billu-frontend/src/constants/permissions.jsbrewlabs-billu-frontend/src/context/PermissionContext.jsxbrewlabs-billu-frontend/src/components/shared/ProtectedRoute.jsxbrewlabs-billu-frontend/src/components/shared/Sidebar.jsxbrewlabs-billu-frontend/src/components/sidebar/PermissionSidebar.jsxbrewlabs-billu-frontend/src/components/shared/Header.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/hooks/useProducts.jsbrewlabs-billu-frontend/src/hooks/useRealtimeAvailability.jsbrewlabs-billu-frontend/src/hooks/useSafeQueries.jsbrewlabs-billu-frontend/src/utils/imageUpload.jsbrewlabs-billu-frontend/src/utils/outletManager.jsbrewlabs-billu-frontend/src/utils/index.jsbrewlabs-billu-frontend/src/utils/safeDataUtils.jsbrewlabs-billu-frontend/src/utils/apiDataMappers.jsbrewlabs-billu-frontend/src/utils/orderNormalization.jsbrewlabs-billu-frontend/src/utils/mappers.jsbrewlabs-billu-frontend/src/test/setup.jsbrewlabs-billu-frontend/src/design-system/index.jsbrewlabs-billu-frontend/src/pages/Orders.jsxbrewlabs-billu-frontend/src/pages/Kitchen.jsx