Runs in the browser: components, hooks, router
Build time and tooling: Vite, TS, tests
Talks to the backend: fetch, cache, tokens
Where a patient in a hurry feels it
VELORA
FRONTEND ARCHITECTURE
REACT 18 · TS · VITE

Frontend

a client-rendered React 18 + TypeScript app that talks to the Velora REST API over /api/v1: how the routes, components, state, hooks and API client fit together, from typing a medicine name to holding the last strip

1 · Stack and rendering: client-rendered on purpose

Results change every minute and nobody needs an emergency search page in Google, so there's nothing to pre-build (no SSG) and no SEO reason for server rendering (no SSR). A small Vite SPA loads once and then only moves JSON.

React 18 + TypeScriptfunction components and hooks only; shared types for API responses
Vitedev server with /api proxy to localhost:4000; production build to dist/
React Router v6createBrowserRouter, nested layouts, loaders not used
TanStack Queryevery piece of data that came from the API: caching, refetch, retries
React Hook Form + Zodforms with the same rules the API validates
Tailwind CSSmobile-first utility classes, one theme file for colours
2 · Folder structure: grouped by feature, not by file type

Everything for one feature (its pages, components, hooks and API calls) lives together, so working on holds means opening one folder.

client/src/
├─ main.tsx              providers: QueryClient, AuthProvider, Router
├─ app/
│  ├─ router.tsx         createBrowserRouter, lazy routes
│  ├─ RootLayout.tsx     header, toaster, error boundary
│  ├─ RequireAuth.tsx
│  └─ RequireRole.tsx
├─ api/
│  ├─ client.ts          apiFetch: token, refresh, ApiError
│  └─ types.ts           Pharmacy, StockItem, Hold, User
├─ features/
│  ├─ auth/              LoginPage RegisterPage AuthContext
│  ├─ search/            SearchPage ResultCard useSearch
│  ├─ pharmacy/          PharmacyDetailPage
│  ├─ holds/             MyHoldsPage usePlaceHold useCountdown
│  ├─ pharmacist/        Onboarding Stock Hours IncomingHolds
│  └─ admin/             Verifications Compliance SearchIndex
├─ components/ui/        Button Input Badge Skeleton Modal Toast
├─ hooks/                useDebounce useGeolocation useOnline
└─ lib/                  schemas.ts format.ts constants.ts
features/*a feature can import from components/, hooks/, lib/ and api/, never from another feature
components/uino data fetching, only props in, markup out; used by every feature
api/the only folder that calls fetch; components never do
lib/schemas.tsZod schemas for every form, matching the API's validate middleware
3 · Route map: every page, who can see it, what it calls

Route groups mirror the backend's roles. Hiding a page here is convenience only; the API's RBAC middleware is the real check.

PathWhoPageCalls
/publicSearch: medicine box, location, resultsGET /search, /search/medicines, /search/areas
/pharmacy/:idpublicPharmacy detail: address, phone, hours, stockGET /pharmacies/:id/public
/login, /registerpublicAuth forms; login returns to the page you came fromPOST /auth/login, /auth/register
/holdsPATIENTMy holds with a live countdown and CancelGET /holds/me, PATCH /holds/:id/cancel
/pharmacist/onboardingPHARMACISTRegister pharmacy + licence, then shows PENDING / REJECTED reasonPOST /pharmacies, GET /pharmacies/mine
/pharmacist/stockPHARMACISTStock table with inline quantity and price editsGET/POST/PATCH /pharmacies/:id/stock
/pharmacist/hoursPHARMACIST7-day hours editor, 24x7 togglePUT /pharmacies/:id/hours
/pharmacist/holdsPHARMACISTIncoming holds with Mark collectedGET /pharmacies/:id/holds, PATCH .../collect
/admin/verificationsADMINPending list, licence details, Approve / Reject with reasonGET /admin/pharmacies, PATCH .../verify|reject
/admin/complianceADMINTabs: expiring licences, missing hours, expired holds; SuspendGET /admin/compliance/*
/admin/search-indexADMINCount of unsynced rows and a Rebuild buttonPOST /admin/search-index/rebuild
*public404 page with a link back to search—
4 · Layouts and guards: how a protected page decides to render

Each wrapper does one job, in this order, from the outside in.

RootLayoutheader, offline banner, toaster, <Outlet/>; wraps everything in a route error boundary
RequireAuthwhile AuthContext is restoring the session: spinner; no user: <Navigate to="/login" state={from}/>
RequireRolerole not in the allowed list: 403 page, never a blank screen
Suspense + React.lazypharmacist and admin sections load as separate chunks only for those roles
// app/router.tsx
const PharmacistArea = lazy(() => import('../features/pharmacist/PharmacistArea'));
const AdminArea      = lazy(() => import('../features/admin/AdminArea'));

export const router = createBrowserRouter([{
  element: <RootLayout />, errorElement: <RouteError />,
  children: [
    { path: '/',             element: <SearchPage /> },
    { path: '/pharmacy/:id', element: <PharmacyDetailPage /> },
    { path: '/login',        element: <LoginPage /> },
    { path: '/register',     element: <RegisterPage /> },
    { element: <RequireAuth />, children: [
      { element: <RequireRole roles={['PATIENT']} />,
        children: [{ path: '/holds', element: <MyHoldsPage /> }] },
      { element: <RequireRole roles={['PHARMACIST']} />,
        children: [{ path: '/pharmacist/*', element: <Suspense fallback={<PageSkeleton/>}><PharmacistArea/></Suspense> }] },
      { element: <RequireRole roles={['ADMIN']} />,
        children: [{ path: '/admin/*', element: <Suspense fallback={<PageSkeleton/>}><AdminArea/></Suspense> }] },
    ]},
    { path: '*', element: <NotFound /> },
  ],
}]);
5 · Screens: what each page is made of

Container pages own the data; the pieces inside only take props.

SearchPagereads ?medicine and ?area from the URL
  • SearchBar with suggestions, debounced 300 ms
  • LocationPicker: "Use my location" or area list
  • ResultsList: skeleton / error / empty / cards
  • AlternativesList when nothing is found
ResultCardone pharmacy, all from props
  • name, distance "1.2 km", OpenNowTag
  • StockBadge CONFIRMED / STALE with "updated 20 min ago"
  • price, prescription tag
  • HoldButton, DirectionsLink, phone number
MyHoldsPagepatient's holds, newest first
  • HoldCard with mm:ss countdown
  • status chip ACTIVE / COLLECTED / CANCELLED / EXPIRED
  • Cancel with an in-page confirm step
  • pharmacy address and Directions
Pharmacist areatabs under one layout
  • Onboarding form, then status banner
  • StockTable: inline edit, add medicine search
  • HoursEditor: 7 rows, 24x7 switch, past-midnight hint
  • IncomingHolds: Mark collected
Admin areatabs under one layout
  • VerificationQueue: licence details, Approve, Reject + reason
  • ComplianceTabs: three tables from the SQL reports
  • Suspend / Reinstate with reason
  • SearchIndex: unsynced count, Rebuild
Auth pagesshort forms, big inputs
  • Login: email, password, show/hide
  • Register: role picker PATIENT / PHARMACIST
  • server errors shown on the field they belong to
  • after login: back to where you were, e.g. the Hold you tapped
6 · State: four kinds, kept in four places

The most common React mistake is putting API data into useState or a global store. Each kind of state has a different lifetime, so each gets its own tool.

Server statecame from the API and can go stale
  • TanStack Query cache
  • search results, holds, stock, pending list
Auth statewho is logged in
  • AuthContext + useReducer
  • user, accessToken, status
  • LOGIN, TOKEN_REFRESHED, LOGOUT
URL stateshould survive reload and be shareable
  • useSearchParams
  • ?medicine=, ?area=, admin tab
UI statelives only on this screen
  • useState
  • modal open, inline edit row, show password
Query keyEndpointstaleTimeRefetchInvalidated by
['search', medicine, lat, lng]GET /search30 severy 60 s + on window focusplace hold, cancel hold
['holds', 'me']GET /holds/me0on focus; when a countdown hits 0place hold, cancel hold
['pharmacy', id]GET /pharmacies/:id/public60 son focus—
['mine'], ['stock', id]GET /pharmacies/mine, /stock0on focusstock edit, add medicine
['incoming', id]GET /pharmacies/:id/holds0every 30 smark collected
['admin', 'pharmacies', status]GET /admin/pharmacies0on focusverify, reject, suspend, reinstate
['areas']GET /search/areasInfinitynever—
Access token lives in memory only, inside AuthContext. On a page reload the app calls POST /auth/refresh once; the browser sends the httpOnly cookie, and the user is back without typing anything. Logout clears the context and the whole query cache so no one else's data stays on screen.
7 · Custom hooks: components never call fetch

Every data call and every browser API is wrapped in a hook, so components stay small and the logic can be tested without rendering anything.

HookDoesReturns
useAuth()reads AuthContext; exposes login, logout, registeruser, role, status, login(), logout()
useSearch(medicine, loc)useQuery for GET /search; disabled until both are setdata, isLoading, isError, isFetching, refetch
useMedicineSuggest(q)debounced suggestions, only after 2 characterssuggestions[]
usePlaceHold()useMutation for POST /holds; invalidates search + holdsmutate, isPending, error
useCancelHold()useMutation, optimistic status change, rolls back on errormutate, isPending
useGeolocation()navigator.geolocation once, 8 s timeoutcoords, status: idle / asking / ok / denied / timeout
useDebounce(value, 300)delays a changing valuedebounced value
useCountdown(expiresAt)ticks every second with setInterval, cleans up on unmountmsLeft, label "12:48", isExpired
useOnline()listens to online / offline eventsisOnline
8 · API client: one place for tokens, refresh and errors

Every request goes through apiFetch. It adds the token, turns error bodies into one ApiError type, and on a 401 runs a single refresh that every waiting request shares.

1. Build the requestbase /api/v1, JSON headers, credentials: 'include', Bearer token if logged in
2. Got 401?start one refresh (or join the one already running), then retry the request once
3. Refresh faileddispatch LOGOUT, clear the cache, send to /login with the current page saved
4. Other errorsthrow ApiError(status, message, details); the component decides what to show
// api/client.ts
let refreshing: Promise<string | null> | null = null;   // shared by every 401

async function refreshOnce() {
  refreshing ??= fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include' })
    .then(r => (r.ok ? r.json() : null))
    .then(body => { const t = body?.accessToken ?? null; auth.setToken(t); return t; })
    .finally(() => { refreshing = null; });
  return refreshing;
}

export async function apiFetch<T>(path: string, init: RequestInit = {}, retried = false): Promise<T> {
  const res = await fetch('/api/v1' + path, {
    ...init, credentials: 'include',
    headers: { 'Content-Type': 'application/json',
               ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
               ...init.headers },
  });
  if (res.status === 401 && !retried && !path.startsWith('/auth/')) {
    const token = await refreshOnce();
    if (token) return apiFetch<T>(path, init, true);
    auth.logout();                                        // clears cache, goes to /login
  }
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new ApiError(res.status, body.error?.message ?? 'Something went wrong', body.error?.details);
  }
  return res.status === 204 ? (undefined as T) : res.json();
}
9 · Forms and validation

React Hook Form keeps typing fast; Zod checks the same rules the API checks, so most mistakes are caught before a request is sent. The API still validates everything.

// lib/schemas.ts
export const onboardingSchema = z.object({
  name:          z.string().min(3),
  address:       z.string().min(10),
  area:          z.string(),
  lat:           z.number().min(-90).max(90),
  lng:           z.number().min(-180).max(180),
  phone:         z.string().regex(/^[6-9]\d{9}$/, 'Enter a 10-digit mobile number'),
  licenseNumber: z.string().min(5),
  issueDate:     z.coerce.date(),
  expiryDate:    z.coerce.date(),
}).refine(d => d.expiryDate > new Date(),
          { path: ['expiryDate'], message: 'Licence has already expired' });
Client checkerrors under each field as you leave it; submit disabled while sending
Server 400details field errors are mapped back with setError(field)
Server 409e.g. email already registered: shown on the email field
Never lose inputa failed submit keeps every value; nothing is cleared on error
10 · Every screen has six states, not one

A blank screen in an emergency reads as "the app is broken". Each data view designs all six.

Loadingthree card-shaped skeletons, never a lone spinner on the search page
Refreshingold results stay visible with a thin "updating" bar (isFetching)
Empty"No open pharmacy has this within 5 km" + widen to 10 km + same-salt alternatives
Errorplain message from ApiError + Retry; 5xx never shows raw text
StaleSTALE badge: "stock last updated 9 h ago, call before you go" with the phone number
Offlinebanner from useOnline; last results stay on screen, Hold disabled
11 · Emergency UX and accessibility

Designed for one thumb, a slow phone, bad light and a stressed person.

Search firstthe home page is the search box, focused on load; no splash, no login wall
Big targetsevery button at least 44 x 44 px; Hold and Directions full-width on phones
Contrasttext at least 4.5:1; badges use words as well as colour (CONFIRMED / STALE)
Keyboard + screen readervisible focus ring, labels on every input, results list announced with aria-live
Location fallbackif location is denied, pick an area from the list; the search still works
Directionsa Google Maps link with the pharmacy's lat,lng, no API key; phone number shown as text
12 · Performance

The number that matters is time from opening the app to the first useful result on a mid-range phone on 4G.

Split by rolepharmacist and admin code never downloads for patients (React.lazy)
Debouncesuggestions wait 300 ms after typing stops
Memoised cardsResultCard wrapped in React.memo; handlers from useCallback so a 60 s refetch doesn't redraw every card
Prefetchpressing a card prefetches ['pharmacy', id] before navigation
No imagesicons as inline SVG; one system font stack, no web font download
Paged results20 at a time with "Show more", matching the API's limit
target: first result under 3 s on "Fast 4G" in Chrome DevTools
initial JS for the patient path under 200 KB gzipped
Lighthouse mobile: performance and accessibility at least 90
13 · Build, environment and deploy

The frontend and API look like one site to the browser, so the refresh cookie is first-party and there is no CORS to configure.

// vite.config.ts: dev only
server: { proxy: { '/api': 'http://localhost:4000' } }

// vercel.json: production
{
  "rewrites": [
    { "source": "/api/:path*",
      "destination": "https://velora-api.herokuapp.com/api/:path*" },
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

// package.json scripts
"dev": "vite", "build": "tsc -b && vite build",
"lint": "eslint .", "test": "vitest"
Vercelbuilds on every push to main; preview link for every branch
ESLint + Prettierreact-hooks rules on, so missing effect dependencies are caught
TypeScript strictAPI response types in api/types.ts; build fails on type errors
SPA fallbackthe last rewrite sends every page path to index.html so reloading /holds works
14 · Testing: the parts that would hurt if they broke

Vitest + React Testing Library. Test behaviour a user would notice, not implementation details.

api/client.tstwo 401s at once trigger exactly one refresh; a failed refresh logs out
RequireRolePATIENT on /admin sees the 403 page; logged-out user is sent to /login and back
useCountdownwith fake timers: label counts down, isExpired flips at 0, interval cleared on unmount
HoldButtondisabled while pending; 409 shows "Just missed it" and refetches results
SearchPageloading, empty and error states each render their own UI
Formsexpired licence date is rejected before submit; server 400 lands on the right field