`if (!isEdible(category)) return null` sat on the line above `useQuery`, which made the hook conditional. React counts hooks per component instance, so one drawer reused for two products -- a soap and then a biscuit, which is ordinary browsing in the global catalogue -- went 0 hooks then 1 and threw "rendered more hooks than during the previous render". That does not degrade the panel, it unmounts the tree: the health score then disappears for EVERY product until the page is reloaded, which reads exactly like the feature having been switched off. The guard now sits after the hook, and `enabled` carries the intent the early return was protecting -- a non-food product still asks the service nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nearle Platform Console
Nearle's own console, used by Nearle staff at platform.nearledaily.com: the tenant directory, onboarding, the global catalogue, delivery partners and platform-wide dispatch. Built against the existing Fiesta backend — the API is a fixed constraint, not something this repo changes.
This is not the merchant console
Merchants and their branch users sign in at app.nearledaily.com, which is a
separate repository (nearle-console) with its own deploy. Only nearle-admin
accounts can sign in here; a merchant account is refused with a sentence naming
where it belongs, and the refusal happens before any session is written.
The two were one application with three workspaces behind a role guard, and were split so an internal tool and a customer-facing product could move at their own pace — and so a change made for staff could not reach a shop.
They share no code at runtime, and roughly thirty thousand lines are duplicated between them: the API layer, the query cache, the component library, the drawers. That is a deliberate trade, taken because this side is internal — a divergence here is something the team notices in its own tool rather than something a merchant discovers. A fix worth having in both has to be made twice, on purpose.
Folders named store-admin remain under src/features/. They are not merchant
screens: they are the shared pieces this console depends on — the dispatch board,
the drawer kit, formatting, assignment logic — still carrying the name they had
before the split.
Running it
npm install
npm run dev # http://localhost:3100
npm run typecheck # tsc --noEmit
npm run build # typecheck + production build
Port 3100, not 3000. The old console (
daily_merchant_web) runs its dev server on 3000.strictPortis on, so if 3100 is taken this fails loudly rather than silently moving — which is the failure that makes you think your changes did not land when you are actually looking at a different app.
In development Vite proxies /fiesta → https://fiesta.nearle.app. For a
deployed build set VITE_API_BASE (see .env.example).
Stack
| Layer | Choice |
|---|---|
| Build | Vite 8 |
| Language | TypeScript 7, strict, noUncheckedIndexedAccess. No .js/.jsx anywhere. |
| UI | React 19 + @astryxdesign/core |
| Theme | src/theme/nearle.ts, compiled to nearle.css |
| Routing | React Router 7, split per page |
| Server state | TanStack Query 5 |
| Icons | lucide-react |
| Spreadsheets | xlsx, loaded on demand |
What's built
All three workspaces. Which one opens is decided by the account, not chosen —
see src/auth/roles.ts.
Nearle Admin (issuperadmin) — the platform operator:
/nearle/stores— every tenant, branch counts, per-tenant performance/nearle/stores/:tenantId— one tenant's branches, orders and revenue/nearle/onboard/tenant— provision a merchant group and its first outlet/nearle/catalogue— the global catalogue, plus both product-import paths/nearle/partners— rider partners, and the riders under each/nearle/dispatch— every partner's live work and shifts/nearle/uploads— spreadsheets sent to the catalogue service
Store Admin (roleid 1 and 3) — one merchant, every branch:
/admin/console— online and counter sales side by side, per branch/admin/sales·/admin/dispatch·/admin/inventory·/admin/reports/admin/branches/new— commission an outlet with its delivery thresholds/admin/users— back-office people and till accounts/admin/terminals·/admin/uploads·/admin/profile·/admin/onboarding
Store user (everything else) — one branch, scoped to it:
/store/console·/store/products·/store/sales·/store/dispatch/store/reports·/store/customers·/store/terminals·/store/staff/store/uploads·/store/account·/store/setup
Two routes are redirects rather than pages, and deliberately:
/nearle/onboard/branch → /nearle/stores (a branch is commissioned from the
tenant that will own it), and /nearle/fleet → /nearle/dispatch.
Things about the backend that shape this code
These are not bugs in this repo. They are the API's behaviour, and each one is worked around deliberately.
- No web authentication. The login endpoints return the user record and no
token; only
/v1/pos/*has middleware. So the session is that record, held insessionStorage.src/auth/session.tsis the only file that changes when tokens arrive. - Every list call needs a scoping id or it 400s. The IDOR pass added
controller-level guards. Query hooks are therefore
enabled-gated on the id and the id is part of the query key. - Roles are derived, not asserted.
issuperadminis checked beforeroleid, because the flag is server-derived and a roleid is not. Roleids 7 and 8 are till roles and never reach an admin workspace. POST /products/createtakes one product and returns no id. The controller passes the struct to the service by value, so GORM writes the generated id into a copy that is then discarded. The sheet importer therefore creates, then re-queries by SKU to resolve ids, then batches the location and stock writes. SeeimportSheetProductsinsrc/api/products.ts.- The two import paths are not symmetric. Catalogue import is one idempotent batch call. Sheet import is N creates with no dedupe on SKU, so re-uploading a file duplicates its products — the UI says so before the button.
- The global catalogue has a price range, not a price, and no mapping to a tenant's own categories. Category, subcategory, retail price, cost and tax are collected before an import can be enabled.
- POS endpoints take one required
locationid. There is no tenant-wide counter-sales call, so a multi-branch POS view fans out per branch.
The 30-second cadence
Online orders land when an order is placed; counter sales reach the console on a
30-second refetch; anything still sitting on an offline till has not arrived at
all. So any figure blending the two is eventually consistent, and the
<Freshness> component exists to say when a number was last true and how many
bills are still stranded. Polling is applied per query in src/queries/hooks.ts
rather than globally — a provisioning form has no business re-polling.
Design
The visual system is ported from KROW (which is built on Astryx too, so the
tokens are role-for-role comparable), with Nearle purple #662582 as the accent.
The ambient canvas in index.css — a fixed, viewport-wide horizontal gradient at
z-index: -1 — is the one piece of KROW that carries most of the character.
Radius, type scale and control heights are matched to KROW in
src/theme/nearle.ts, with the reasoning kept in comments.
Open: the dark-mode accent #B57FD0 was chosen for 5.41:1 contrast on
Astryx's dark surface and still needs brand-owner sign-off before dark mode
ships.