Skip to content

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.

FileWhat it doesWhy
src/services/apiService.jsCreates a second instance with its own copy of the interceptorsOlder 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.jsCreates a refreshInstance next to its axiosWrapper callsThe comment in the file explains it, a refresh call from inside the refresh interceptor would loop
src/utils/imageUpload.jsPosts multipart uploads with raw axiosNo interceptors means no automatic outlet header, so prefer axiosWrapper for any new upload

Know which headers axiosWrapper injects

HeaderSourceNotes
Authorizationstate.user.token, then localStorage.accessTokenPresent only when a token exists
x-panel-typeConstantAlways TENANT
x-outlet-idRedux outlet.currentOutletId, then outlet.currentOutlet.id, then localStorage.ACTIVE_OUTLET_IDRequired for every non exempt tenant route
x-organization-idstate.user.organization.id, state.user.organizationId, then localStorage.ACTIVE_ORG_IDPresent 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.

OrderSourceUsed when
1Redux currentOutletId or currentOutlet.idAlways preferred
2localStorage.ACTIVE_OUTLET_IDRedux 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();
CallAnswers
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.

FileWhat it does
src/components/auth/RoleGuard.jsxCompares user.role against an allowedRoles prop. Nothing imports it, it is not in the route tree
src/components/auth/OutletSelection.jsxCompares user.role strings to decide whether to show outlet onboarding
src/context/PermissionContext.jsxShort 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.

FieldMeaning
menu.screen or menu.keyThe menu key used by ALL_MENUS
menu.visible or menu.allowedDrives isMenuAllowed
menu.actionsObject of action flags read by can
menu.labelDisplay 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 appearsAction 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.jsupdate

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

  1. Make the backend return the menu in /api/me/menu-permissions with a screen key, a visible flag and an actions object. src/context/PermissionContext.jsx reads exactly those fields.
  2. Add the permission strings to PERMISSIONS in src/constants/permissions.js.
  3. Add those strings to the relevant role lists in ROLE_PERMISSIONS in the same file. src/redux/slices/outletSlice.js uses those lists as the last fallback when neither outlet permissions nor user permissions exist.
  4. Register the menu key in ALL_MENUS in src/config/menuActionsConfig.js, with key, label, route, icon and the actions array. Add the key to MENU_GROUPS when it should appear in the sidebar.
  5. Add the key to the actionPerms table inside fetchPermissions in src/context/PermissionContext.jsx when the new actions should also produce string permissions for hasPermission. Skip this step when the feature only needs menu level checks.
  6. Gate the route in src/App.jsx with ProtectedRoute, using menuKey and optionally action.
<Route element={<ProtectedRoute menuKey="inventory" />}>
<Route path="inventory" element={<InventoryDashboard />} />
</Route>
  1. Gate buttons and menu items inside the screen with can(menuKey, action).
  2. 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.

ToolUse
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.

Terminal window
npx playwright install chromium

playwright.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.

CommandWhat it doesNeeds the backend
npm testVitest, single runNo
npm run test:watchVitest in watch modeNo
npm run test:e2e -- --project=chromiumPlaywright, chromium project onlyYes
npm run test:e2ePlaywright, every project in playwright.config.jsYes
npm run linteslint .No
npm run buildRegenerates the service worker, then buildsNo

Review checklist

Before you open a pull request, confirm each line.

  • New requests use axiosWrapper or a named function from src/https/index.js.
  • New query keys include the outlet id when the data is outlet scoped.
  • New route groups carry a ProtectedRoute guard.
  • New buttons and menu entries ask usePermission instead of comparing roles.
  • New cached data shapes come with a CACHE_VERSION bump.
  • New unit tests mock axiosWrapper and sit in a __tests__ folder next to the code.

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/scripts/generate-sw.js
  • brewlabs-billu-frontend/tests/e2e/helpers.js
  • brewlabs-billu-frontend/tests/e2e/orders.spec.js
  • brewlabs-billu-frontend/tests/e2e/auth.spec.js
  • brewlabs-billu-frontend/tests/e2e/kitchen.spec.js
  • brewlabs-billu-frontend/src/App.jsx
  • brewlabs-billu-frontend/src/index.css
  • 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/https/__tests__/index.test.js
  • brewlabs-billu-frontend/src/services/apiService.js
  • brewlabs-billu-frontend/src/services/tableService.js
  • brewlabs-billu-frontend/src/services/productService.js
  • brewlabs-billu-frontend/src/services/inventoryService.js
  • brewlabs-billu-frontend/src/services/reportsService.js
  • brewlabs-billu-frontend/src/auth/authService.js
  • brewlabs-billu-frontend/src/redux/slices/outletSlice.js
  • brewlabs-billu-frontend/src/redux/slices/organizationSlice.js
  • brewlabs-billu-frontend/src/redux/slices/userSlice.js
  • brewlabs-billu-frontend/src/configs/queryClient.js
  • brewlabs-billu-frontend/src/config/menuActionsConfig.js
  • brewlabs-billu-frontend/src/constants/permissions.js
  • brewlabs-billu-frontend/src/context/PermissionContext.jsx
  • brewlabs-billu-frontend/src/components/shared/ProtectedRoute.jsx
  • brewlabs-billu-frontend/src/components/shared/Sidebar.jsx
  • brewlabs-billu-frontend/src/components/sidebar/PermissionSidebar.jsx
  • brewlabs-billu-frontend/src/components/shared/Header.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/hooks/useProducts.js
  • brewlabs-billu-frontend/src/hooks/useRealtimeAvailability.js
  • brewlabs-billu-frontend/src/hooks/useSafeQueries.js
  • brewlabs-billu-frontend/src/utils/imageUpload.js
  • brewlabs-billu-frontend/src/utils/outletManager.js
  • brewlabs-billu-frontend/src/utils/index.js
  • brewlabs-billu-frontend/src/utils/safeDataUtils.js
  • brewlabs-billu-frontend/src/utils/apiDataMappers.js
  • brewlabs-billu-frontend/src/utils/orderNormalization.js
  • brewlabs-billu-frontend/src/utils/mappers.js
  • brewlabs-billu-frontend/src/test/setup.js
  • brewlabs-billu-frontend/src/design-system/index.js
  • brewlabs-billu-frontend/src/pages/Orders.jsx
  • brewlabs-billu-frontend/src/pages/Kitchen.jsx