import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import type { PriceUnit } from '@/services/catalog/types'; import { SEARCH_PAGE_SIZE } from '../constants'; import type { NurseGender, NurseProfile, NurseProfileServiceRow, NurseReviewSnippet, NurseSearchFilters, NurseSearchResult, SearchApi, } from '../types'; const SEARCH_BASE = '/api/v1/search'; const NURSES_BASE = '/api/v1/nurses'; /** The b7 `NurseSearchResultDto` (the projected index row) — the exact wire shape we map from. */ interface NurseSearchResultDto { variantId: number; nurseId: number; serviceCategoryId: number; price: string; priceUnit: PriceUnit; nurseGender: NurseGender; averageRating: number; totalReviews: number; 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; /** REQ-040 (proposed) — not yet served; absent until the backend lands it. */ topReviewTag?: string | null; } /** 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/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()`. * * 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> => { const query = new URLSearchParams(); query.set('service_category_id', String(filters.serviceCategoryId)); query.set('city_id', String(filters.cityId)); if (filters.districtId != null) query.set('district_id', String(filters.districtId)); if (filters.nurseGender) query.set('nurse_gender', filters.nurseGender); if (filters.priceMin) query.set('min_price', filters.priceMin); if (filters.priceMax) query.set('max_price', filters.priceMax); if (filters.priceUnit) query.set('price_unit', filters.priceUnit); query.set('page', String(filters.page || 1)); query.set('page_size', String(filters.pageSize || SEARCH_PAGE_SIZE)); const paged = unwrap( await clientFetch>>( `${SEARCH_BASE}/nurses?${query.toString()}`, ), ); return { ...paged, items: paged.items.map((dto) => ({ nurseId: dto.nurseId, variantId: dto.variantId, serviceCategoryId: dto.serviceCategoryId, // 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, distanceKm: dto.distanceKm, priceFromIrr: dto.price, priceUnit: dto.priceUnit, nurseGender: dto.nurseGender, cityId: dto.cityId, districtId: dto.districtId, topReviewTag: dto.topReviewTag ?? null, })), }; }, getNurseProfile: async (nurseId: number): Promise => { const dto = unwrap( await clientFetch>(`${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: 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', }; }, };