refinement phase 4

This commit is contained in:
hamid
2026-07-13 12:29:00 +03:30
parent 314763f764
commit 64f6aa45c9
27 changed files with 308 additions and 243 deletions
+71 -33
View File
@@ -1,11 +1,12 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
import type { TrustBadge } from '@/services/verification/types';
import { SEARCH_PAGE_SIZE } from '../constants';
import type {
NurseGender,
NurseProfile,
NurseProfileServiceRow,
NurseReviewSnippet,
NurseSearchFilters,
NurseSearchResult,
SearchApi,
@@ -27,23 +28,44 @@ interface NurseSearchResultDto {
totalCompletedBookings: number;
cityId: number;
districtId: number | null;
/** REQ-012 — the C2 card identity, now denormalized into the index row. */
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
}
/** The INO-membership credential type code (see b6 verification). */
const INO_MEMBERSHIP_CODE = 'ino_membership';
/** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
interface NursePublicProfileDto {
nurseId: number;
nurseName: string;
avatarUrl: string | null;
bio: string;
yearsExperience: number;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
isVerified: boolean;
inoMembership: boolean;
attributeChips: string[];
services: {
variantId: number;
displayName: string;
priceIrr: string;
priceUnit: PriceUnit;
sessionCount: number | null;
}[];
latestReview: { rating: number; body: string | null; authorMasked: string | null; createdAt: string } | null;
}
/**
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6/b7 public profile). Routes are
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`.
*
* NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6
* trust badge is public. Both gaps are filed in
* `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6
* currently provide (leaving the missing fields blank) so the swap is a single config flip once the
* backend lands the join + profile route.
* The PRIMARY implementation once `USE_SEARCH_MOCK = false` (refinement-phase-4). REQ-012 delivered the
* discovery enrichment the C2 card + C3 profile need: `nurseName`/`avatarUrl`/`distanceKm` are now
* denormalized onto `NurseSearchResultDto`, and `GET nurses/{id}/profile` aggregates identity + bio +
* specialties + the full services list + the latest review. This client maps both 1:1.
*/
export const searchClientApi: SearchApi = {
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
@@ -70,16 +92,15 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId,
variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId,
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label.
nurseName: '',
avatarUrl: null,
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: dto.nurseName ?? '',
avatarUrl: dto.avatarUrl,
// Every returned row is searchable by the index invariant.
isVerified: true,
averageRating: dto.averageRating,
totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings,
// Gap (filed): no geo-distance in the index row yet.
distanceKm: null,
distanceKm: dto.distanceKm,
priceFromIrr: dto.price,
priceUnit: dto.priceUnit,
nurseGender: dto.nurseGender,
@@ -90,26 +111,43 @@ export const searchClientApi: SearchApi = {
},
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank.
const badge = unwrap(
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
const dto = unwrap(
await clientFetch<ApiEnvelope<NursePublicProfileDto>>(`${NURSES_BASE}/${nurseId}/profile`),
);
const services: NurseProfileServiceRow[] = dto.services.map((s) => ({
variantId: s.variantId,
displayName: s.displayName,
priceIrr: s.priceIrr,
priceUnit: s.priceUnit,
sessionCount: s.sessionCount,
}));
const latestReview: NurseReviewSnippet | null = dto.latestReview
? {
rating: dto.latestReview.rating,
body: dto.latestReview.body ?? '',
authorMasked: dto.latestReview.authorMasked ?? '',
createdAt: dto.latestReview.createdAt,
}
: null;
return {
nurseId: badge.nurseId,
nurseName: '',
avatarUrl: null,
bio: null,
yearsExperience: null,
averageRating: 0,
totalReviews: 0,
totalCompletedBookings: 0,
isVerified: badge.isVerified,
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE),
attributeChips: badge.credentialTypes,
services: [],
latestReview: null,
nurseId: dto.nurseId,
nurseName: dto.nurseName,
avatarUrl: dto.avatarUrl,
bio: dto.bio || null,
yearsExperience: dto.yearsExperience,
averageRating: dto.averageRating,
totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings,
isVerified: dto.isVerified,
inoMembership: dto.inoMembership,
attributeChips: dto.attributeChips,
services,
latestReview,
// Not carried by the public profile DTO; the same-gender intent for booking comes from the C1
// filter carried through the query string, not this field. Unused by the C3 page.
nurseGender: 'female',
};
},