Files
baya-monorepo/client/src/services/search/apis/clientApi.ts
T
2026-07-18 09:51:03 +03:30

158 lines
5.7 KiB
TypeScript

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<Paginated<NurseSearchResult>> => {
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<ApiEnvelope<Paginated<NurseSearchResultDto>>>(
`${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<NurseProfile> => {
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: 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',
};
},
};