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:
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* When true, the profiles domain is served by the in-memory mock behind the ProfilesApi
|
||||
* seam. The b3 `customer_profiles/*` and `nurse_profiles/*` endpoints are live, but the
|
||||
* avatar/object-storage route and the customer name/preferred-language fields are gaps
|
||||
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
|
||||
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_PROFILES_MOCK = true;
|
||||
|
||||
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
|
||||
export const PROFILE_STALE_TIME = 60_000;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import { PROFILE_STALE_TIME } from '../constants';
|
||||
|
||||
/** The signed-in customer's payer profile (emergency contact + name). `null` until created. */
|
||||
export function useCustomerProfile() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: profileKeys.customer(),
|
||||
queryFn: () => profilesApi.getCustomerProfile(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: PROFILE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import { PROFILE_STALE_TIME } from '../constants';
|
||||
|
||||
/** The signed-in nurse's own seller profile. `null` until bootstrapped (B7). */
|
||||
export function useNurseProfile() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: profileKeys.nurse(),
|
||||
queryFn: () => profilesApi.getNurseProfile(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: PROFILE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { profilesApi } from '../apis';
|
||||
|
||||
/**
|
||||
* Uploads an avatar image and returns its URL. The caller folds the returned URL into the
|
||||
* next profile upsert. Backed by the mock until the object-storage route lands (REQ-006).
|
||||
*/
|
||||
export function useUploadAvatar() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => profilesApi.uploadAvatar(file),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authKeys } from '@/services/auth/keys';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import type { UpsertCustomerProfileInput } from '../types';
|
||||
|
||||
/**
|
||||
* Creates/updates the customer profile. Writes the fresh profile straight into cache and
|
||||
* invalidates `/me` so a profile-completion change reflects in the Home nudge (b3 also
|
||||
* auto-provisions a thin customer profile, flipping `hasCustomerProfile`).
|
||||
*/
|
||||
export function useUpsertCustomerProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpsertCustomerProfileInput) => profilesApi.upsertCustomerProfile(input),
|
||||
onSuccess: (profile) => {
|
||||
queryClient.setQueryData(profileKeys.customer(), profile);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authKeys } from '@/services/auth/keys';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import type { UpsertNurseProfileInput } from '../types';
|
||||
|
||||
/**
|
||||
* Bootstraps (first entry) or edits the nurse profile via the single b3 upsert. `isVerified`
|
||||
* is never sent — it stays server-owned/false. Writes the fresh profile to cache and
|
||||
* invalidates `/me` so `hasNurseProfile` reflects the bootstrap.
|
||||
*/
|
||||
export function useUpsertNurseProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpsertNurseProfileInput) => profilesApi.upsertNurseProfile(input),
|
||||
onSuccess: (profile) => {
|
||||
queryClient.setQueryData(profileKeys.nurse(), profile);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useCustomerProfile } from './hooks/useCustomerProfile';
|
||||
export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile';
|
||||
export { useNurseProfile } from './hooks/useNurseProfile';
|
||||
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
|
||||
export { useUploadAvatar } from './hooks/useUploadAvatar';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* React Query key factory for the profiles domain. The customer and nurse profiles are
|
||||
* distinct owner-scoped resources, each its own key.
|
||||
*/
|
||||
export const profileKeys = {
|
||||
all: ['profiles'] as const,
|
||||
customer: () => [...profileKeys.all, 'customer'] as const,
|
||||
nurse: () => [...profileKeys.all, 'nurse'] as const,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Profiles domain — the nurse *seller* profile and the customer *payer* profile. Shapes
|
||||
* mirror the b3 contract (`dev/contracts/domains/identity-profiles.md` → `NurseProfileDto`,
|
||||
* `CustomerProfileDto`) exactly. The bank account is a separate domain (`services/nurse`).
|
||||
*
|
||||
* Client-augmented fields (not on the wire yet — filed in requests/for-backend.md):
|
||||
* - nurse `avatarUrl` — no avatar/object-storage route in b3 (REQ-006).
|
||||
* - customer `firstName`/`lastName`/`preferredLanguage` — the wire `CustomerProfileDto`
|
||||
* carries only the emergency contact; name lives on `/me` with no update endpoint (REQ-007).
|
||||
* The mock persists these; the real client sends only the wire fields.
|
||||
*/
|
||||
|
||||
/** `NurseProfileDto` — `isVerified` + the three aggregates are server-owned and read-only. */
|
||||
export interface NurseProfileDto {
|
||||
id: number;
|
||||
bio: string;
|
||||
yearsOfExperience: number;
|
||||
educationLevel: string;
|
||||
educationField: string;
|
||||
/** Raw JSON array string of specialization codes (the builder is deferred to f4). */
|
||||
specializationsJson: string;
|
||||
isVerified: boolean;
|
||||
isAcceptingBookings: boolean;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
}
|
||||
|
||||
export interface NurseProfile extends NurseProfileDto {
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
/** `nurse_profiles/upsert` body — never carries `isVerified` or the aggregates. */
|
||||
export interface UpsertNurseProfileInput {
|
||||
bio: string;
|
||||
yearsOfExperience: number;
|
||||
educationLevel: string;
|
||||
educationField: string;
|
||||
specializationsJson: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
/** `CustomerProfileDto` — emergency contact returned in full to the owning customer. */
|
||||
export interface CustomerProfileDto {
|
||||
id: number;
|
||||
defaultEmergencyContactName: string;
|
||||
defaultEmergencyContactPhone: string;
|
||||
}
|
||||
|
||||
export interface CustomerProfile extends CustomerProfileDto {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
preferredLanguage: string | null;
|
||||
}
|
||||
|
||||
/** `customer_profiles/upsert` body (emergency contact) + client-augmented name/language. */
|
||||
export interface UpsertCustomerProfileInput {
|
||||
defaultEmergencyContactName: string;
|
||||
defaultEmergencyContactPhone: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
}
|
||||
|
||||
export interface AvatarUploadResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** The domain's API seam. `get*` resolve to `null` when the caller has no profile yet (404). */
|
||||
export interface ProfilesApi {
|
||||
getCustomerProfile(): Promise<CustomerProfile | null>;
|
||||
upsertCustomerProfile(input: UpsertCustomerProfileInput): Promise<CustomerProfile>;
|
||||
getNurseProfile(): Promise<NurseProfile | null>;
|
||||
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
|
||||
uploadAvatar(file: File): Promise<AvatarUploadResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user