6.9 KiB
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.