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
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.
dist/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
Route groups mirror the backend's roles. Hiding a page here is convenience only; the API's RBAC middleware is the real check.
| Path | Who | Page | Calls |
|---|---|---|---|
| / | public | Search: medicine box, location, results | GET /search, /search/medicines, /search/areas |
| /pharmacy/:id | public | Pharmacy detail: address, phone, hours, stock | GET /pharmacies/:id/public |
| /login, /register | public | Auth forms; login returns to the page you came from | POST /auth/login, /auth/register |
| /holds | PATIENT | My holds with a live countdown and Cancel | GET /holds/me, PATCH /holds/:id/cancel |
| /pharmacist/onboarding | PHARMACIST | Register pharmacy + licence, then shows PENDING / REJECTED reason | POST /pharmacies, GET /pharmacies/mine |
| /pharmacist/stock | PHARMACIST | Stock table with inline quantity and price edits | GET/POST/PATCH /pharmacies/:id/stock |
| /pharmacist/hours | PHARMACIST | 7-day hours editor, 24x7 toggle | PUT /pharmacies/:id/hours |
| /pharmacist/holds | PHARMACIST | Incoming holds with Mark collected | GET /pharmacies/:id/holds, PATCH .../collect |
| /admin/verifications | ADMIN | Pending list, licence details, Approve / Reject with reason | GET /admin/pharmacies, PATCH .../verify|reject |
| /admin/compliance | ADMIN | Tabs: expiring licences, missing hours, expired holds; Suspend | GET /admin/compliance/* |
| /admin/search-index | ADMIN | Count of unsynced rows and a Rebuild button | POST /admin/search-index/rebuild |
| * | public | 404 page with a link back to search | — |
Each wrapper does one job, in this order, from the outside in.
<Outlet/>; wraps everything in a route error boundary<Navigate to="/login" state={from}/>// 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 /> },
],
}]);
Container pages own the data; the pieces inside only take props.
?medicine and ?area from the URL
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.
| Query key | Endpoint | staleTime | Refetch | Invalidated by |
|---|---|---|---|---|
['search', medicine, lat, lng] | GET /search | 30 s | every 60 s + on window focus | place hold, cancel hold |
['holds', 'me'] | GET /holds/me | 0 | on focus; when a countdown hits 0 | place hold, cancel hold |
['pharmacy', id] | GET /pharmacies/:id/public | 60 s | on focus | — |
['mine'], ['stock', id] | GET /pharmacies/mine, /stock | 0 | on focus | stock edit, add medicine |
['incoming', id] | GET /pharmacies/:id/holds | 0 | every 30 s | mark collected |
['admin', 'pharmacies', status] | GET /admin/pharmacies | 0 | on focus | verify, reject, suspend, reinstate |
['areas'] | GET /search/areas | Infinity | never | — |
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.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.
| Hook | Does | Returns |
|---|---|---|
useAuth() | reads AuthContext; exposes login, logout, register | user, role, status, login(), logout() |
useSearch(medicine, loc) | useQuery for GET /search; disabled until both are set | data, isLoading, isError, isFetching, refetch |
useMedicineSuggest(q) | debounced suggestions, only after 2 characters | suggestions[] |
usePlaceHold() | useMutation for POST /holds; invalidates search + holds | mutate, isPending, error |
useCancelHold() | useMutation, optimistic status change, rolls back on error | mutate, isPending |
useGeolocation() | navigator.geolocation once, 8 s timeout | coords, status: idle / asking / ok / denied / timeout |
useDebounce(value, 300) | delays a changing value | debounced value |
useCountdown(expiresAt) | ticks every second with setInterval, cleans up on unmount | msLeft, label "12:48", isExpired |
useOnline() | listens to online / offline events | isOnline |
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.
/api/v1, JSON headers, credentials: 'include', Bearer token if logged inApiError(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();
}
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' });
details field errors are mapped back with setError(field)A blank screen in an emergency reads as "the app is broken". Each data view designs all six.
Designed for one thumb, a slow phone, bad light and a stressed person.
The number that matters is time from opening the app to the first useful result on a mid-range phone on 4G.
['pharmacy', id] before navigationThe 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"
Vitest + React Testing Library. Test behaviour a user would notice, not implementation details.