frontend phase 2: onboarding & profiles — customer/patient, nurse profile & bank

Turns a logged-in user into a usable account, consuming the b3 identity-profiles
contract behind the services/{domain} seam.

Services (mock default true; real HTTP clients wired for a one-line flip):
- services/patients: rewritten to b3 PatientDto + client-augmented relation/conditions;
  full CRUD, optimistic soft-archive, cache-splice on create, age<->birthDate helper.
- services/profiles: customer + nurse profile get/upsert + avatar (404->null mapping).
- services/nurse: payout bank accounts + IBAN(Sheba) util + pending-only polling.

Screens: A3->A4 onboarding wizard, E1 patients list/CRUD, A5 home (first-login gate +
nudge), customer profile (no national-ID), nurse profile bootstrap (unverified
placeholder), nurse bank settings (pending/verified/mismatch + make-primary).

Shared composites (each tested): GenderToggle, ConditionChips, RelationSelect,
PatientForm, PatientCard, BankStatusPanel; reuses f0 StepperHeader/StatusChip/PhoneField.
Adds onboarding/home/profile/nurseProfile/bank i18n namespaces (both locales, in sync),
the --bal-primary-soft token, and nurse sidebar Profile + Bank entries.

Contract gaps filed: REQ-005 (patient relation/conditions), REQ-006 (avatar route),
REQ-007 (customer name/language). Gate: check + 112 tests + build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:04:38 +03:30
parent 82561c4cc6
commit 4b4243c451
70 changed files with 3111 additions and 190 deletions
@@ -0,0 +1,86 @@
import { clientFetch } from '@/lib/api/client';
import { ApiError } from '@/lib/api/errors';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type {
AvatarUploadResult,
CustomerProfile,
CustomerProfileDto,
NurseProfile,
NurseProfileDto,
ProfilesApi,
UpsertCustomerProfileInput,
UpsertNurseProfileInput,
} from '../types';
const BASE = '/api/v1';
// A caller with no profile yet gets a 404 from the GET — map that to `null` (an empty form),
// not an error. Any other status propagates.
async function orNull<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields.
function toNurseProfile(dto: NurseProfileDto): NurseProfile {
return { ...dto, avatarUrl: null };
}
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile {
return { ...dto, firstName: null, lastName: null, preferredLanguage: null };
}
/**
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only.
*/
export const profilesClientApi: ProfilesApi = {
getCustomerProfile: async () =>
orNull(
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)),
),
),
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile(
unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
}),
}),
),
),
getNurseProfile: async () =>
orNull(
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
),
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
toNurseProfile(
unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
bio: input.bio,
yearsOfExperience: input.yearsOfExperience,
educationLevel: input.educationLevel,
educationField: input.educationField,
specializationsJson: input.specializationsJson,
}),
}),
),
),
uploadAvatar: async (): Promise<AvatarUploadResult> => {
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
},
};
@@ -0,0 +1,10 @@
import { USE_PROFILES_MOCK } from '../constants';
import type { ProfilesApi } from '../types';
import { profilesClientApi } from './clientApi';
import { profilesMockApi } from './mockApi';
/**
* The selected ProfilesApi implementation — the single seam hooks import. Selection is by
* config (USE_PROFILES_MOCK), never by scattered `if (mock)` checks.
*/
export const profilesApi: ProfilesApi = USE_PROFILES_MOCK ? profilesMockApi : profilesClientApi;
@@ -0,0 +1,73 @@
import { sleep } from '@/utils';
import type {
AvatarUploadResult,
CustomerProfile,
NurseProfile,
ProfilesApi,
UpsertCustomerProfileInput,
UpsertNurseProfileInput,
} from '../types';
const MOCK_LATENCY_MS = 350;
// Both profiles start absent (a fresh user has none — the real GET would 404). Bootstrapping
// via upsert creates them; `isVerified` and the aggregates stay server-owned defaults.
let customerProfile: CustomerProfile | null = null;
let nurseProfile: NurseProfile | null = null;
/**
* In-memory mock behind the ProfilesApi seam. Mirrors the b3 shapes and keeps the guarded
* read-only fields (`isVerified=false`, zero aggregates) exactly as the server would, so a
* bootstrapped nurse is never presented as verified/bookable.
*/
export const profilesMockApi: ProfilesApi = {
getCustomerProfile: async () => {
await sleep(MOCK_LATENCY_MS);
return customerProfile;
},
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => {
await sleep(MOCK_LATENCY_MS);
customerProfile = {
id: customerProfile?.id ?? 1,
defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
firstName: input.firstName ?? customerProfile?.firstName ?? null,
lastName: input.lastName ?? customerProfile?.lastName ?? null,
preferredLanguage: input.preferredLanguage ?? customerProfile?.preferredLanguage ?? null,
};
return customerProfile;
},
getNurseProfile: async () => {
await sleep(MOCK_LATENCY_MS);
return nurseProfile;
},
upsertNurseProfile: async (input: UpsertNurseProfileInput) => {
await sleep(MOCK_LATENCY_MS);
nurseProfile = {
id: nurseProfile?.id ?? 1,
bio: input.bio,
yearsOfExperience: input.yearsOfExperience,
educationLevel: input.educationLevel,
educationField: input.educationField,
specializationsJson: input.specializationsJson,
// Server-owned, guarded — a bootstrapped profile is never verified or bookable.
isVerified: false,
isAcceptingBookings: nurseProfile?.isAcceptingBookings ?? false,
averageRating: 0,
totalReviews: 0,
totalCompletedBookings: 0,
avatarUrl: input.avatarUrl ?? nurseProfile?.avatarUrl ?? null,
};
return nurseProfile;
},
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
await sleep(MOCK_LATENCY_MS);
// Object URL reflects the actual picked image for the demo; the real impl returns an
// object-storage URL (REQ-006).
return { url: URL.createObjectURL(file) };
},
};