21 KiB
CLAUDE.md — NearlExpress Console (xpressconsole)
Project-level rules and conventions for Claude Code when working in this repo. Read this in full before editing. When in doubt about a pattern, copy from
src/pages/nearle/deliveries/deliveries.js— it is the canonical reference for both the design system and the data layer.
1. What this is
A React 18 operator console for the NearlExpress dispatch platform. Operators use it to manage orders, run the AI dispatch optimiser, watch a live map of riders, edit tenants/pricing/invoices, and pull BI reports. Production users are warehouse staff, not end customers.
For the per-page API map and architectural flow chart, see the project skill nearlexpress-docs (.claude/skills/nearlexpress-docs/SKILL.md). Do not duplicate that content here.
2. Stack (pinned — do not upgrade without a deliberate audit)
- React 18.2 + react-app-rewired 2.2 (CRA, not Next.js — no
pages/file-system routing; routes live insrc/routes/MainRoutes.js). - MUI 5.12 (
@mui/material) is the primary UI library. Ant Design (antd 5.11) is used only for<Empty />placeholders in legacy pages — prefer MUI for new work. - Icons:
@ant-design/icons(page actions:EditOutlined,EyeOutlined,CloseOutlined, etc.) andreact-icons(page identity:Md*,Tb*,Fi*,Gi*,Lu*). Both coexist; pick by what's already imported nearby. - Data layer:
@tanstack/react-query 5.17(useQuery,useInfiniteQuery,useMutation). All server fetches go through this. Do not introducefetch,swr, oruseEffect-driven fetches for new code. - HTTP:
axios 1.3. Most pages importaxiosdirectly (raw).src/utils/axios.jsexists with a 401 →/logininterceptor but is not widely adopted — match the surrounding file rather than introducing it. - State:
@reduxjs/toolkit 1.9for cross-page state (FCM token, login user, menu, snackbar, toast). Page-local UI state stays inuseState. - Routing:
react-router-dom 6.10, lazy-loaded viacomponents/Loadable. - Forms:
formik 2.2+yup 1.1where present; new simple forms can use plainuseState. - Dates:
dayjs 1.11withutcplugin (already extended at the top ofdeliveries.js). Usedayjs(...).utc()for backend timestamps and baredayjs(...)for local-time bucketing — see the batch-bucketing comment indeliveries.jsfor the rationale. - Maps:
leaflet+react-leaflet, plus@react-google-maps/apifor Google. Geocoding viareact-geocode. Maps API key isprocess.env.REACT_APP_GOOGLE_MAPS_API_KEY. - Notifications:
firebase 10.14(FCM) — seesrc/firebase_notification/. Toasts vianotistack 3. - Drag-and-drop:
react-dnd(used on the Dispatch Preview page).
3. Dev workflow
# Install
npm install # or `yarn`
# Run locally (uses .env)
npm start
# Run with a specific env file
npm run start:dev # env.development
npm run start:staging # env.staging
# Build
npm run build
npm run build:dev
npm run build:staging
# Lint (the only checked gate — there are no tests of consequence)
npm run lint
- Env files at repo root:
env.stagingis committed;.env.development/.env.productionare typically gitignored. Pull from a teammate when missing. - Required env vars:
REACT_APP_URL(primary API base),REACT_APP_URL2(secondary API base — used for/users/update,/tenants/update,/partners/getriderlogs, archival/orders/getorders),REACT_APP_GOOGLE_MAPS_API_KEY. The optimiser URLs (routes.workolik.com,routemate.workolik.com) and the Jupiter auth URL (jupiter.nearle.app) are hardcoded — see thenearlexpress-docsskill. - Dev server runs on
http://localhost:3000. The user usually has it running already — assume it is up when reporting "reload to see it".
4. Hard constraints (do NOT)
- Do not introduce Next.js / SSR patterns. No
getServerSideProps, noapp/directory, nonext/*imports. This is CRA. - Do not rewrite shared design tokens. Every new page that needs the polished UI must reuse the
DTtoken block (see §6). Do not invent a parallel palette. - Do not change
package.jsondependency versions unless explicitly asked. The build is sensitive to webpack/svgr/react-scripts versions (seeresolutionsinpackage.json). - Do not bypass the dispatch reconcile step. After any manual edit on
/nearle/dispatch/preview(rider swap, step reorder), the page must callPOST /optimization/reconcile-stepsbeforePOST /deliveries/createdeliveries. Skipping this corrupts route sequences. - Do not commit
.env*files beyondenv.staging(which is the agreed-shared staging baseline). - Do not introduce TypeScript files (
.ts/.tsx) into this repo. It is JavaScript; mixing creates lint and tooling friction. - Do not use absolute
http://localhostURLs in code. Always read fromprocess.env.REACT_APP_URL/REACT_APP_URL2. - Do not log
userid,authname, FCM tokens, or PII toconsole.login production paths. The codebase has many leftoverconsole.logcalls — when editing nearby, remove them rather than add more. - Do not add or remove items from the sidebar without updating
src/menu-items/nearle.js— the menu drives both display and i18n keys. - Do not use destructive git (
reset --hard,push --force, branch deletion) without explicit user instruction.
5. Architecture map
src/
├── App.js # Auth gate (localStorage('authname') → /login), mounts FCM listener, ThemeCustomization, Locales, Notistack, Snackbar
├── index.js # Provider wiring: Redux store, TanStack QueryClient, Router
├── config.js # Theme constants (DRAWER_WIDTH=260, fontFamily, mode, presetColor, ThemeMode, MenuOrientation, ThemeDirection)
├── routes/
│ ├── index.js # Combines MainRoutes + LoginRoutes
│ ├── MainRoutes.js # All /nearle/* routes, lazy-loaded via Loadable(lazy(...))
│ └── LoginRoutes.js
├── layout/
│ ├── MainLayout/ # Sidebar + header frame (used for all logged-in pages)
│ └── CommonLayout/ # Bare frame (login, maintenance pages)
├── menu-items/
│ └── nearle.js # Sidebar definition — id, title (FormattedMessage), url, icon. Must add new pages here.
├── store/
│ ├── index.js # configureStore + useDispatch/useSelector exports
│ └── reducers/ # fcmSlice, loginUserSlice, menu, snackbar, toastSlice, auth, actions
├── themes/ # MUI theme, palette, typography, shadows, overrides
├── pages/
│ ├── api/api.js # CENTRAL API layer — every query/mutation function lives here
│ └── nearle/ # All operator pages, grouped by feature folder
└── components/
├── Loadable.js # React.Suspense wrapper for lazy routes
├── Loader.js # Full-page backdrop spinner
├── nearle_components/
│ ├── DebounceSearchBar.js # 500ms-debounced search input with ⌘/Ctrl+K focus shortcut
│ ├── LocationAutocomplete.js # Zone picker (has `pill` variant — use it for new pages)
│ ├── LoaderWithImage.js
│ ├── GlobalToast.js
│ ├── SearchBar.js
│ ├── TableLoader.js
│ └── TitleCard.js # LEGACY — do not use for new pages (replaced by the gradient Paper header in §6)
└── third-party/
└── OpenToast.js # Wrapper around notistack — use this for toast emissions
- Absolute imports work from
src/thanks tojsconfig.json("baseUrl": "src"). Preferimport x from 'pages/api/api'over deep relative paths. Look at the surrounding file's import style and match it.
6. Design system (the DT token block)
The polished pages (deliveries.js, clients/Tenants.js, clientPricing/clientPricing.js) share a token block at the top of the file. Every new operator page must paste and reuse this block — do not invent fresh colours, spacings, or radius numbers.
const DT = {
radiusPill: 999,
radiusCard: 16,
radiusInner: 12,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
// Alpha-suffix helpers — append hex transparency to any accent colour.
const a = (c, suffix) => `${c}${suffix}`;
const tint = (c) => a(c, '08'); // very subtle surface tint
const soft = (c) => a(c, '18'); // soft chip / avatar bg
const ring = (c) => a(c, '26'); // focus ring
const edge = (c) => a(c, '55'); // resting border
// Pill-style filter inputs.
const pillFieldSx = (color) => ({
'& .MuiOutlinedInput-root': {
borderRadius: DT.radiusPill + 'px',
bgcolor: tint(color),
fontWeight: 600,
'& fieldset': { borderColor: edge(color), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: color },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(color)}` },
'&.Mui-focused fieldset': { borderColor: color, borderWidth: 2 }
}
});
// Soft Paper used by all Autocomplete popups (matches the deliveries batch dropdown).
const SoftPaper = (props) => (
<Paper {...props} sx={{ mt: 0.75, borderRadius: 2, boxShadow: DT.shadowPop, border: '1px solid', borderColor: 'divider', overflow: 'hidden' }} />
);
// Colored avatar — flips between filled and soft based on `selected`.
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar sx={{
width: size, height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}>{children}</Avatar>
);
Page anatomy (every operator page should follow this order)
Use brand purple
#662582(with light variant#9255AB) for every brand surface below. Don't use indigo#6366f1— that's reserved for the "Accepted" status badge only.
- Gradient header
<Paper>—linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%), 48px filled#662582avatar with a page icon,Typography variant="h3"title, "Live · {zone}" sub-line with an 8px green pulsing dot, and a pillLocationAutocompleteon the right (pill accentColor="#662582" paperComponent={SoftPaper}). - KPI tiles row —
Gridof 3–4Papercards, each with a 3px top stripe gradient, uppercase eyebrow label, large bold number, and a soft-tinted avatar holding an icon. The "primary" tile uses brand purple#662582; other tiles use semantic status colours. - Filter bar
<Paper>(optional) — pill-styleAutocompletes usingpillFieldSx(color)+SoftPaper, each with anAccentAvatarstart-adornment. - Pill status tabs + pill search — tabs as clickable
<Box>pills (active = filled accent + glow ring, inactive =tintbg +edgeborder), each with an avatar icon and a count badge. Tab accents use the semantic status colour for each tab (pending → amber, delivered → emerald, etc.). Search viaDebounceSearchBarstyled with brand purple#662582(tint bg, edge border, focus ring). - Table
<Paper>with<TableContainer>+ sticky<TableHead>— uppercase muted headers onDT.surfaceAlt, rows withborderBottom: 1px solid ${DT.divider}and:hoverrow tint. Scrollbar thumb uses brand purpleedge('#662582'). - Status badges in cells —
StackwithAccentAvatar+ label inside a soft pill (tint bg, edge border). Status colours come from a per-pageSTATUS_METAmap keyed by lowercase status string — these are semantic, not brand. - Edit / action icon buttons — soft-pill
IconButtonusing brand purple#662582(NOT#8b5cf6— that overlaps with the "Picked" status badge and confuses operators). - Empty state — centered 64px avatar (soft grey), bold "No X to show" line, and a muted helper sentence. Do not use antd
<Empty />for new code.
Universal brand colour
#662582 — NearlExpress brand purple. This is the canonical primary colour for this app, defined in src/themes/theme/default.js as primary.main. It drives the sidebar, the logo, and is what every new surface (page headers, KPI primary tile, search bars, edit-action buttons, dialog/popup headers, scrollbars) must use as the brand accent.
Variants (also from theme/default.js):
| Token | Hex | Use |
|---|---|---|
primary.lighter |
#E8D9EF |
Very subtle wash bg |
primary.light / primary.400 |
#9255AB |
Gradient pair with main |
primary.main |
#662582 |
Brand primary — default for all brand surfaces |
primary.dark |
#4D1C61 |
Hover / pressed states |
primary.darker |
#260E30 |
Deep contrast text on light bg |
Page header / dialog header gradient: linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%) (subtle wash) or linear-gradient(135deg, #662582 0%, #9255AB 100%) (solid, for dialog titles).
Migration note:
deliveries.js,Tenants.js, andclientPricing.jscurrently use#6366f1(indigo) as their brand accent — a holdover from the first design pass before brand purple was canonicalised. They are scheduled to migrate to#662582.customers.jsand the createorder1 Saved-Address dialog have already been migrated.
Status palette (semantic — distinct from brand)
These colour-code lifecycle states. Do not swap them for brand purple — operators rely on the colour to identify status at a glance.
| Meaning | Colour |
|---|---|
| Pending / waiting | #f59e0b (amber) |
| Accepted / assigned | #6366f1 (indigo — semantically distinct from brand purple) |
| Arrived | #06b6d4 (cyan) |
| Picked up | #8b5cf6 (light purple — distinct from brand) |
| Active / in-transit | #14b8a6 (teal) |
| Delivered / success | #10b981 (emerald) |
| Cancelled / error | #ef4444 (red) |
| Skipped | #f97316 (orange) |
| Neutral / muted | #94a3b8 (slate) |
| Sky accent (tenant, info) | #0ea5e9 |
Don'ts for the design system
- Don't use raw MUI
<Tabs>for status/filter switching — they were replaced by the pill<Box>pattern on every redesigned page. Pages that still use<Tabs>(e.g. for inline collapse views) are tolerated but new top-level navigation should use pills. - Don't reach for
purple lighter/e1bee7or other Mantis theme accents on new pages — those exist in legacy code only. - Don't apply box-shadow to row hover; only tint the background (
DT.surfaceAlt). - Don't change avatar sizes mid-page — pick from
{18, 20, 22, 24, 28, 32, 36, 40, 44, 48, 56, 64}and stay consistent.
7. Data layer rules
- Every server call belongs in
src/pages/api/api.jsas an exported async function. Page files should never construct URLs inline forGETs. Mutations are sometimes kept inline (e.g. tenant pricing update) — that's tolerated but discouraged. - Use TanStack Query for all reads. Query keys must include every filter that affects the response so the cache invalidates correctly. Example:
useQuery({ queryKey: ['fetchCountData', appId, userid, startdate, enddate, tenantid, locationid, riderid, tabstatus], queryFn: () => fetchCountAPI(appId, userid, ...) }); - Use
useInfiniteQueryfor paginated rows.getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined. Auto-drain pages with anIntersectionObserveron a sentinel<div ref={loadMoreRef} />placed at the bottom of the<TableContainer>. The canonical pattern lives indeliveries.js(search foruseInfiniteQuery({and the adjacentIntersectionObserversetup). - Mutations go through
useMutationwithonSuccess/onError. After a successful mutation, call.refetch()on every related query — the codebase does not yet usequeryClient.invalidateQueries. Match the surrounding file. - Errors →
OpenToast(message, 'error', 2000)fromcomponents/third-party/OpenToast. Toasts always anchor top-right. Duration is 2000ms for normal, 3000ms for "must be acknowledged". - Skeleton loading: use
OrdersTableSkeletonfor tables (props:rowsPerPagedefault 5,coldefault 1 —colis the count of variable middle columns; total rendered columns =col + 4since checkbox, serial, notes, status, and actions are always present). UseLoaderfor full-page modal backdrop andLoaderWithImagefor inline "loading deliveries…" states.
8. State & auth
- Auth state lives in
localStorage. The keys to know:authname(gate inApp.js),userid,roleid,userfcmtoken,applocations(cached zone list). When adding auth-touching code, read these directly — there is nouseAuth()hook. - The 401 redirect comes from
src/utils/axios.js. Most pages bypass that interceptor by importing rawaxios. If you need guaranteed 401 handling for a new flow, import fromutils/axiosinstead. - Redux slices live in
src/store/reducers/. Use them only for cross-page state (FCM token, login user, sidebar menu open, global snackbar). Do not put per-page form state in Redux.
9. FCM / notifications
- Initialised once in
App.jsviagenerateToken()+initFirebaseNotificationListener()fromfirebase_notification/notification.js. - After any mutation that affects a rider (assign, cancel, change rider), call
POST /utils/notifyuserwith the target rider'suserfcmtoken. Don't forget to also callnotifyRidermutation on success. - Service worker file:
public/firebase-messaging-sw.js— do not edit unless the user explicitly asks. Subtle bugs here cause silent delivery failures.
10. Routing & adding a new page
- Create the page file under
src/pages/nearle/<feature>/<name>.js. - Add the lazy import at the top of
src/routes/MainRoutes.js:const MyPage = Loadable(lazy(() => import('pages/nearle/<feature>/<name>'))); - Register the route inside the
nearlechildren array. - Add a sidebar entry in
src/menu-items/nearle.js(id,<FormattedMessage id="..." />title, url, icon). - Add the i18n key in the relevant
src/utils/locales/*.jsonfile (or wherever the project keeps them; most existing items use the English string as the id).
11. Common gotchas
utcplugin pollution:deliveries.jsextends dayjs withutcat module load. If you import dayjs elsewhere and call.utc()you'll get UTC behaviour even if you didn't ask. Match what the surrounding page does —deliveries.jsdeliberately bucket-parses in local time, not UTC, to stay in sync with the dispatch page.- Two API bases: most calls hit
REACT_APP_URL. A handful hitREACT_APP_URL2(/users/update,/tenants/update, archival orders, rider logs). Checkpages/api/api.jsbefore changing one. - The
applocationidquery param: every list endpoint expects this —0means "All Zones". Pages defaultappId = 0and update it fromLocationAutocomplete. rolegating useslocalStorage.getItem('roleid'). Some buttons are conditionally rendered based on it. Do not hide UI based on string equality alone — check existing patterns.- Skeleton vs Loader vs LoaderWithImage — these are different. Skeleton = per-row placeholder, Loader = full-screen backdrop blocking interaction, LoaderWithImage = inline branded spinner. Don't swap them.
- Maps API key is read from env on every render in some places — wrapping it in a
useMemois fine but unnecessary. Don't add new directprocess.env.REACT_APP_GOOGLE_MAPS_API_KEYreads outside map-related files. Tenants.jshas known dangling references (setClientstatus,setState,setSuburb,setTenanatPricing,<Collapse in={open}>) inherited from legacy code. They are tolerated. Do not "fix" them as a drive-by — they are out of scope and removing them risks breaking the row collapse contents.
12. Communication style for changes
- For UI changes, the user runs the dev server at
http://localhost:3000. After editing a page, end with one short sentence telling them which route to reload (e.g. "Reload http://localhost:3000/nearle/pricing to see it"). - Don't commit unless asked. The project has
package-lock.json+yarn.lockboth present — match the user's last commit's lockfile choice before suggestingnpm installvsyarn install. - Verify after edits with a quick JSX parse check (the user has
acornavailable innode_modules) — do not assume the build will pass just because Edit succeeded. - Mark
// removed/// unusedcomments as code smells — delete dead code instead of commenting it out, unless the user explicitly says "keep it commented for now".
13. Cross-references
- For the per-page API map and the architectural flow chart (which endpoint each page calls, what the optimisation pipeline does, FCM flow), invoke the project skill
nearlexpress-docs(.claude/skills/nearlexpress-docs/SKILL.md) rather than restating the content here. - For shared design patterns between pages, the source of truth is the
DTtoken block and helpers near the top ofsrc/pages/nearle/deliveries/deliveries.js(search forconst DT = {). Copy from there, don't redesign.