158 lines
5.7 KiB
TypeScript
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',
|
|
};
|
|
},
|
|
};
|