Web client for the ParcelFlow API — parcel delivery management with three roles, a strictly linear status flow, and an append-only audit trail.
A static single-page app: Vite 6, React 19, TypeScript, Tailwind CSS 4, React Router 7, TanStack Query 5. No backend of its own — it talks to the API over HTTPS with a Bearer token.
- Live API:
https://parcelflowapi.onrender.com - Deploys to: Vercel (static
dist/)
- Quick start
- Environment
- Deploying to Vercel
- Connecting to the Render backend
- Demo accounts
- What each role can do
- Project structure
- Notes on a few decisions
- Troubleshooting
npm install
cp .env.example .env.local # already points at the deployed API
npm run dev # http://localhost:5173| Command | |
|---|---|
npm run dev |
Dev server with HMR |
npm run build |
Typecheck (tsc -b) then build to dist/ |
npm run preview |
Serve the built dist/ on :4173 |
npm run typecheck |
Types only |
To develop against a local API instead, set VITE_API_BASE_URL=http://localhost:3000 in
.env.local and restart the dev server.
One variable:
VITE_API_BASE_URL=https://parcelflowapi.onrender.com
No trailing slash, no path. Vite inlines VITE_* at build time, not run time — the value is
baked into the JS bundle, so changing it on Vercel needs a redeploy; saving the variable alone
does nothing.
Leaving it unset falls back to /api, the same-origin proxy described under
Client-side blocking.
The footer of every page shows the API host it resolved, with a live health dot — the quickest way to confirm a deployment picked up the right value.
This repo is the project root, so no "Root Directory" override is needed.
-
Vercel → Add New → Project, import
ALPHAMAN-0/parcelFlow_Frontend. -
Vercel detects Vite. Confirm the defaults:
- Framework preset: Vite
- Build command:
npm run build - Output directory:
dist - Install command:
npm install
-
Under Environment Variables, add — for Production, Preview and Development:
Key Value VITE_API_BASE_URLhttps://parcelflowapi.onrender.com -
Deploy.
vercel.json is committed and handles three things:
/api/:path*→ the Render API. Requests become first-party, so ad-blocker filter lists and enterprise URL-blocklist rules that targetonrender.comno longer match them — and CORS stops applying at all. See Client-side blocking.- SPA fallback (
/((?!api/).*)→/index.html) so deep links like/parcels/PF-YUH5F-P6VFXreachindex.htmlinstead of 404ing — without it, every URL except/breaks on refresh. It excludes/apiso it can never shadow the proxy, and it is listed after the proxy because the first matching rewrite wins. - cache headers:
index.htmlrevalidates every time,/assets/*(content-hashed) is immutable for a year.
Keep that reasoning here rather than in the file: Vercel validates vercel.json against a strict
schema that rejects unknown keys, so a "comment" field inside a rewrite fails the build with
The `vercel.json` schema validation failed — and plain JSON has no comment syntax either.
Nothing has to change on the API side. Set VITE_API_BASE_URL to the Render URL and redeploy.
Two reasons it just works:
CORS is already open. ParcelFlowApi/src/app.js uses bare app.use(cors()), so the API answers
Access-Control-Allow-Origin: *. A preflight from a .vercel.app origin returns 204 with
Access-Control-Allow-Headers: content-type, authorization.
No cookies are involved. Auth is an Authorization: Bearer <jwt> header and the client sends
credentials: 'omit'. A wildcard allow-origin forbids credentialed requests, so a cookie-based
client would have needed an API change; this one does not.
Worth knowing about, because it cost real debugging time.
EasyPrivacy — on by default in Brave Shields, uBlock Origin and AdGuard — contains:
||onrender.com/health
|| is a domain anchor covering every subdomain, so that rule blocks /health and
/health/db on any *.onrender.com host. The browser refuses the request itself
(net::ERR_BLOCKED_BY_CLIENT); nothing reaches the server, so no log shows it.
Two things follow:
- The footer's liveness probe therefore uses
GET /, not/health. Same envelope, also never touches the database, and matches no rule in EasyList, EasyPrivacy or uBlock's lists. There is a comment saying so insrc/api/health.ts— don't tidy it back. - The rest of the API was never affected. The rules on this domain are path-scoped
(
||onrender.com/health, and||onrender.com/api/ads/in EasyList); there is no domain-wide block./auth/*,/parcels*and/admin/*all pass. Login and every screen work with Shields up.
vercel.json already carries an inert escape hatch:
{ "source": "/api/:path*", "destination": "https://parcelflowapi.onrender.com/:path*" }Switch VITE_API_BASE_URL to /api and redeploy. Every request then leaves the browser as
same-origin, so no filter list or corporate URLBlocklist can match it, and CORS stops applying
altogether. vite.config.ts mirrors the rewrite for local dev.
It is not free, which is why it is not the default. The API keys its rate limiter on
cf-connecting-ip, which Cloudflare sets to whoever connects directly — behind the proxy that is
Vercel's edge, not the visitor. All visitors then share one bucket:
| Limit | Direct | Proxied |
|---|---|---|
| Login | 10 / 15 min per visitor | 10 / 15 min total |
| Tracking lookup | 60 / min per visitor | 60 / min total |
Verified by probing: spoofing x-forwarded-for does not move the counter (r=57 → r=56 → r=55,
one continuous bucket) and cf-connecting-ip cannot be spoofed from outside — Cloudflare answers
403. Fixing it properly means changing the API's keyGenerator, which is out of scope here.
The clean alternative is a custom domain for the API (api.yourdomain.com → Render): filter
rules scoped to onrender.com stop matching and requests stay direct, so per-visitor limits
survive.
Render's free tier spins a service down after roughly 15 minutes idle. The next request waits 30–60 seconds while it boots. The client handles this rather than failing:
- normal request timeout is 15s;
- on a network-level failure or a 502/503 it retries once with a 70s timeout;
- while that retry is in flight, an amber banner explains the wait.
Retries are deliberately limited to GETs and network failures. A POST /parcels is never retried —
that would risk creating two parcels — and a 409 or 422 is a real answer, not a blip.
Change VITE_API_BASE_URL in Vercel, then redeploy (Deployments → ⋯ → Redeploy). Update
.env.example and .env.local for local work.
For reference — nothing here is managed by this repo:
| Setting | Value |
|---|---|
| Build command | npm install && npx prisma generate && npx prisma migrate deploy |
| Start command | npm start |
| Node version | 22 (the API reads .env via process.loadEnvFile()) |
| Env vars | NODE_ENV=production, DATABASE_URL, DIRECT_URL, JWT_SECRET, JWT_EXPIRES_IN, BCRYPT_ROUNDS, optionally REDIS_URL, SEED_* |
PORT is injected by Render and read by server.js. Do not set it manually.
Seeded on the deployed database. The sign-in page fills these in on a click.
| Role | Password | |
|---|---|---|
| Admin | admin@parcelflow.dev |
admin123 |
| Delivery staff | rahim.staff@parcelflow.dev |
staff123 |
| Delivery staff | karim.staff@parcelflow.dev |
staff123 |
| Customer | ayesha.customer@parcelflow.dev |
customer123 |
| Customer | tanvir.customer@parcelflow.dev |
customer123 |
These are published on the public landing page, including the admin, so anyone who opens the site can administer the demo database. That is usually what you want for a portfolio demo. To lock it down, edit
DEMO_ACCOUNTSinsrc/routes/pages/demoAccounts.ts— drop the admin row, or empty the array to hide the panel entirely.
| Customer | Delivery staff | Admin | |
|---|---|---|---|
| Sees | own parcels | assigned parcels | everything |
| Create a parcel | ✅ | — | ✅ |
| Advance status | — | ✅ own only | ✅ |
| Assign a courier | — | — | ✅ |
| Statistics, users | — | — | ✅ |
The status flow is strictly linear and DELIVERED is terminal:
PENDING → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED
Because there is at most one legal next status from any state, the UI offers a single
"Mark " button rather than a status picker — a dropdown of five would be four guaranteed
409s. DELIVERED gets a confirmation dialog, since it cannot be undone.
The route guards here are for navigation, not security. The API re-checks the role on every request and scopes every query in SQL, so bypassing the client gets you a 403 or an empty list. A parcel outside your scope returns 404, not 403 — a 403 would confirm it exists — and the UI phrases it as "not found, or not visible to your account".
src/
lib/ apiClient.ts the only module that knows HTTP or the response envelope
ApiError.ts typed errors carrying code + details + requestId
errorMessages.ts every API error code → copy a user can act on
config.ts format.ts storage.ts queryKeys.ts cn.ts
domain/ parcelStatus.ts mirrors the API's transition table
trackingCode.ts mirrors the API's code format + alphabet
types/ api.ts transcribed from the API's Prisma selects
api/ auth.ts parcels.ts admin.ts health.ts
hooks/ useAuth.tsx useParcels.ts useAdmin.ts useToast.tsx
useTheme.tsx useApiHealth.ts useDebounced.ts
components/ layout/ ui/ parcels/ admin/
routes/ guards.tsx pages/
src/domain/ deliberately duplicates two small API modules. Each file names its counterpart in a
header comment. The API remains the authority — it re-validates everything — but the copy lets the
UI offer only legal actions and reject a malformed tracking code without spending a request.
Empty query params are dropped, not sent. Every API schema is a Zod strictObject, so
GET /parcels?status= is a hard 422 rather than "no status filter". buildQueryString() in
apiClient.ts omits undefined, null and '', which is what keeps an unset filter from
breaking the page. Same reason ?search= is omitted when the box is empty.
Detail pages are keyed by tracking code, not id. GET /parcels/:trackingCode is the API's
catch-all for any single-segment GET, so GET /parcels/<uuid> returns a 422, not a parcel. That one
request also returns the full history, so the detail page needs exactly one call.
A 401 does not always mean the session died. INVALID_CREDENTIALS is a 401 too, but it means a
wrong password on the login form. The client only ends the session for TOKEN_MISSING,
TOKEN_EXPIRED, TOKEN_INVALID and USER_NOT_FOUND — otherwise a mistyped password would bounce
the user off the page they were trying to use.
Password minimum is 1, not 8. The API's README says 8, but auth.schemas.js is min(1).
Gating at 8 in the client would lock out the seeded demo logins. The max of 72 is real: bcrypt
ignores anything past 72 bytes, so two different long passwords could unlock one account.
Counts come from meta.total. The customer and staff dashboards issue five
GET /parcels?limit=1&status=X reads and read meta.total from each. Five small requests, but
exact — and /parcels is not rate limited, unlike the tracking endpoint (60/min).
No charts library. The statistics bars are ~40 lines of CSS and SVG. A charting dependency would have added tens of kilobytes to draw five rows whose lengths are one division each.
Session is localStorage + revalidation. The token and a cached user paint the app instantly
on reload; GET /auth/me then confirms it. The API re-reads the user from the database on every
request, so a role change an admin made while the tab was closed applies immediately. A network
failure during that check keeps the cached session rather than signing the user out over a blip.
| Symptom | Cause |
|---|---|
net::ERR_BLOCKED_BY_CLIENT on /health |
Expected with Brave Shields / uBlock / AdGuard: EasyPrivacy blocks ` |
ERR_BLOCKED_BY_CLIENT on /auth or /parcels too |
A blocker is targeting more than the health paths. Switch VITE_API_BASE_URL to /api and redeploy. |
| Footer shows the wrong host on a deployed build | VITE_API_BASE_URL changed but the build did not. Redeploy — saving the variable alone is not enough, Vite inlines it at build time. |
| First request after idle takes ~a minute | Render free-tier cold start. The amber banner is expected; it resolves itself. |
Refreshing /parcels/PF-… 404s |
The SPA rewrite in vercel.json is not being applied. Confirm the file is committed and the output directory is dist. |
/api/... returns Vercel's 404 page |
The /api/:path* rewrite is missing or listed after the SPA fallback. The first matching rewrite wins, so it must come first. |
| "Too many sign-in attempts" | The API allows 10 logins per 15 minutes per IP. Wait it out. |
| Every filter returns a validation error | Something is serialising empty params. Filters must be omitted when blank, not sent as ''. |