frontend phase 0: app shells, design system & data/contract patterns
Turn the starter into the Balinyaar foundation for the three actor
experiences and lock in the patterns later phases copy.
- Cleanup: remove toastDemo namespace, placeholder home page, and the two
dead icons; fix BottomBar to use usePathname (locale-aware active tab).
- Three actor shells under (private-routes), no layout above [locale]:
customer (customer) group with the 5-tab bottom nav; nurse (/nurse) and
admin (/admin) on the shared sidebar engine. Role model via constants/roles
+ useActorRole (defaults to customer until roles land in f1-b2).
- services/{domain} reference (patients) with a mock behind a config seam,
hierarchical query keys, deliberate staleTime, and mutation invalidation;
shared ApiEnvelope/Paginated wire types + unwrap() in lib/api/types.
- Money (integer-safe IRR/Toman) + Shamsi-date utils; toEnglishDigits helper.
- Shared composites, each tested: OtpInput, PhoneNumberField, StepperHeader,
StatusChip, PlaceholderScreen.
- i18n: seed nav/common/shell/patients in both locales; document namespace
conventions. Update client/CLAUDE.md Project Structure + fix ColorSchemeScript
doc drift. Add phase report, STATUS, and REQ-001 (envelope/casing/pagination).
Gate: npm run check + test:ci green (72 tests); build green with NEXT_PUBLIC_API_URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,12 @@ export interface AuthTokens {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
import type { AppRole } from '@/constants';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
// Populated by the server in f1-b2. Optional until then; the shell defaults to
|
||||
// the customer experience when roles are absent (see useActorRole).
|
||||
roles?: AppRole[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
|
||||
|
||||
const BASE = '/patients';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the PatientsApi seam. Wired to `clientFetch`, which
|
||||
* returns the raw server envelope — so each call reads the payload via `unwrap`.
|
||||
* Not selected until USE_PATIENTS_MOCK is false and the endpoints exist.
|
||||
*/
|
||||
export const patientsClientApi: PatientsApi = {
|
||||
list: async (params) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.pageSize) query.set('page_size', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const env = await clientFetch<ApiEnvelope<Paginated<Patient>>>(`${BASE}${qs ? `?${qs}` : ''}`);
|
||||
return unwrap(env);
|
||||
},
|
||||
|
||||
create: async (dto: CreatePatientDto) => {
|
||||
const env = await clientFetch<ApiEnvelope<Patient>>(BASE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
return unwrap(env);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_PATIENTS_MOCK } from '../constants';
|
||||
import type { PatientsApi } from '../types';
|
||||
import { patientsClientApi } from './clientApi';
|
||||
import { patientsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected PatientsApi implementation — the single seam hooks import. Selection is
|
||||
* by config (USE_PATIENTS_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const patientsApi: PatientsApi = USE_PATIENTS_MOCK ? patientsMockApi : patientsClientApi;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { sleep } from '@/utils';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 400;
|
||||
|
||||
// In-memory store. Seed timestamps are static strings (not Date.now) so repeated
|
||||
// renders are stable; `create` stamps a real ISO time on the client.
|
||||
let store: Patient[] = [
|
||||
{ id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' },
|
||||
{ id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' },
|
||||
];
|
||||
let nextId = 3;
|
||||
|
||||
/**
|
||||
* In-memory mock behind the PatientsApi seam — the template f1+ follow until the real
|
||||
* `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line
|
||||
* change in constants.ts.
|
||||
*/
|
||||
export const patientsMockApi: PatientsApi = {
|
||||
list: async (params): Promise<Paginated<Patient>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const page = params?.page ?? 1;
|
||||
const pageSize = params?.pageSize ?? 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: store.slice(start, start + pageSize),
|
||||
total: store.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
create: async (dto: CreatePatientDto): Promise<Patient> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const patient: Patient = {
|
||||
id: nextId++,
|
||||
fullName: dto.fullName,
|
||||
gender: dto.gender,
|
||||
createdAtUtc: new Date().toISOString(),
|
||||
};
|
||||
store = [patient, ...store];
|
||||
return patient;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the
|
||||
* PatientsApi seam. Flip to false once the real `/patients` endpoints land — no hook or
|
||||
* component changes are needed (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_PATIENTS_MOCK = true;
|
||||
|
||||
export const PATIENTS_STALE_TIME = 60_000;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import type { CreatePatientDto } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a patient and invalidates every patients list so the cache reflects the new
|
||||
* row without a manual refetch. (setQueryData would also work when the API returns the
|
||||
* full new list item and pagination is trivial — invalidation is the safe default.)
|
||||
*/
|
||||
export function useAddPatient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreatePatientDto) => patientsApi.create(dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import { PATIENTS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* Fetches the patients list. A deliberate staleTime keeps the list warm across remounts
|
||||
* so navigating away and back doesn't refetch what's already cached.
|
||||
*/
|
||||
export function usePatients(params?: PageParams) {
|
||||
return useQuery({
|
||||
queryKey: patientKeys.list(params),
|
||||
queryFn: () => patientsApi.list(params),
|
||||
staleTime: PATIENTS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { usePatients } from './hooks/usePatients';
|
||||
export { useAddPatient } from './hooks/useAddPatient';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the patients domain. Hierarchical keys let a mutation
|
||||
* invalidate every list (`patientKeys.lists()`) without touching unrelated caches.
|
||||
*/
|
||||
export const patientKeys = {
|
||||
all: ['patients'] as const,
|
||||
lists: () => [...patientKeys.all, 'list'] as const,
|
||||
list: (params?: PageParams) => [...patientKeys.lists(), params ?? {}] as const,
|
||||
details: () => [...patientKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...patientKeys.details(), id] as const,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Patients domain — the reference `services/{domain}` implementation every later
|
||||
* frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md);
|
||||
* mirror them as string-literal unions and never hardcode a display label off the code.
|
||||
*
|
||||
* Deriving these types from the contract: read the domain's shapes from the published
|
||||
* `dev/contracts/domains/<domain>.md` + `dev/contracts/openapi/swagger.v1.json`, mirror
|
||||
* the wire exactly (field names + casing), and map enums to unions here. Until the real
|
||||
* `/patients` endpoints exist, the shapes below are the agreed target the mock honours.
|
||||
*/
|
||||
|
||||
export type Gender = 'male' | 'female';
|
||||
|
||||
export interface Patient {
|
||||
id: number;
|
||||
fullName: string;
|
||||
gender: Gender;
|
||||
/** UTC ISO-8601; display via formatShamsiDate. */
|
||||
createdAtUtc: string;
|
||||
}
|
||||
|
||||
export interface CreatePatientDto {
|
||||
fullName: string;
|
||||
gender: Gender;
|
||||
}
|
||||
|
||||
/** The domain's API seam. A mock and the real client both implement this interface. */
|
||||
export interface PatientsApi {
|
||||
list(params?: PageParams): Promise<Paginated<Patient>>;
|
||||
create(dto: CreatePatientDto): Promise<Patient>;
|
||||
}
|
||||
Reference in New Issue
Block a user