backend phase 13 & frontend phase 6

This commit is contained in:
hamid
2026-07-09 04:09:35 +03:30
parent dc64472631
commit de53f9d8a6
97 changed files with 11969 additions and 77 deletions
@@ -0,0 +1,116 @@
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,
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;
}
/** The INO-membership credential type code (see b6 verification). */
const INO_MEMBERSHIP_CODE = 'ino_membership';
/**
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). 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.
*/
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,
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label.
nurseName: '',
avatarUrl: null,
// 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,
priceFromIrr: dto.price,
priceUnit: dto.priceUnit,
nurseGender: dto.nurseGender,
cityId: dto.cityId,
districtId: dto.districtId,
})),
};
},
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`),
);
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,
nurseGender: 'female',
};
},
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_SEARCH_MOCK } from '../constants';
import type { SearchApi } from '../types';
import { searchClientApi } from './clientApi';
import { searchMockApi } from './mockApi';
/**
* The selected SearchApi implementation — the single seam the hooks import. Selection is by config
* (USE_SEARCH_MOCK), never by scattered `if (mock)` checks.
*/
export const searchApi: SearchApi = USE_SEARCH_MOCK ? searchMockApi : searchClientApi;
+130
View File
@@ -0,0 +1,130 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import { SEARCH_PAGE_SIZE } from '../constants';
import type {
NurseProfile,
NurseProfileServiceRow,
NurseSearchFilters,
NurseSearchResult,
SearchApi,
} from '../types';
import { SEED_NURSES, type SeedNurse, type SeedVariant } from './seed';
const MOCK_LATENCY_MS = 300;
/** Flatten every seeded nurse's variants into candidate search rows (one row per variant×area). */
function allRows(): { nurse: SeedNurse; variant: SeedVariant }[] {
return SEED_NURSES.flatMap((nurse) => nurse.variants.map((variant) => ({ nurse, variant })));
}
/**
* The b7 geography rule: a **city-only** search (no `districtId`) matches every row in the city; a
* **district** search matches that district's rows **plus** whole-city (`null`) rows.
*/
function matchesDistrict(rowDistrictId: number | null, filterDistrictId?: number): boolean {
if (filterDistrictId == null) return true;
return rowDistrictId === filterDistrictId || rowDistrictId === null;
}
function withinPrice(priceIrr: string, min?: string, max?: string): boolean {
const value = BigInt(priceIrr);
if (min != null && min !== '' && value < BigInt(min)) return false;
if (max != null && max !== '' && value > BigInt(max)) return false;
return true;
}
function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
return {
nurseId: nurse.nurseId,
variantId: variant.variantId,
serviceCategoryId: variant.serviceCategoryId,
nurseName: nurse.nurseName,
avatarUrl: nurse.avatarUrl,
isVerified: true,
averageRating: nurse.averageRating,
totalReviews: nurse.totalReviews,
totalCompletedBookings: nurse.totalCompletedBookings,
distanceKm: variant.distanceKm,
priceFromIrr: variant.priceIrr,
priceUnit: variant.priceUnit,
nurseGender: nurse.gender,
cityId: variant.cityId,
districtId: variant.districtId,
};
}
/**
* In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort
* semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert)
* demo end-to-end. Mirrors the real shapes for a one-line swap once the backend join/profile endpoints
* land (`USE_SEARCH_MOCK = false`).
*/
export const searchMockApi: SearchApi = {
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
await sleep(MOCK_LATENCY_MS);
if (!(filters.serviceCategoryId > 0) || !(filters.cityId > 0)) {
throw new ApiError(400, 'service_category_id and city_id are required', 'invalid_filters');
}
if (filters.priceMin && filters.priceMax && BigInt(filters.priceMin) > BigInt(filters.priceMax)) {
throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range');
}
const matched = allRows()
.filter(({ nurse, variant }) => {
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
if (variant.cityId !== filters.cityId) return false;
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
return true;
})
// Rating desc, tiebroken by review count then ids so paging is deterministic (contract order).
.sort(
(a, b) =>
b.nurse.averageRating - a.nurse.averageRating ||
b.nurse.totalReviews - a.nurse.totalReviews ||
a.nurse.nurseId - b.nurse.nurseId ||
a.variant.variantId - b.variant.variantId,
)
.map(({ nurse, variant }) => toResult(nurse, variant));
const pageSize = filters.pageSize || SEARCH_PAGE_SIZE;
const page = filters.page || 1;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
await sleep(MOCK_LATENCY_MS);
const nurse = SEED_NURSES.find((candidate) => candidate.nurseId === nurseId);
if (!nurse) throw new ApiError(404, 'Nurse not found', 'not_found');
const services: NurseProfileServiceRow[] = nurse.variants.map((variant) => ({
variantId: variant.variantId,
displayName: variant.displayName,
priceIrr: variant.priceIrr,
priceUnit: variant.priceUnit,
sessionCount: variant.sessionCount,
}));
return {
nurseId: nurse.nurseId,
nurseName: nurse.nurseName,
avatarUrl: nurse.avatarUrl,
bio: nurse.bio,
yearsExperience: nurse.yearsExperience,
averageRating: nurse.averageRating,
totalReviews: nurse.totalReviews,
totalCompletedBookings: nurse.totalCompletedBookings,
isVerified: true,
inoMembership: nurse.inoMembership,
attributeChips: nurse.attributeChips,
services,
latestReview: nurse.latestReview,
nurseGender: nurse.gender,
};
},
};
+180
View File
@@ -0,0 +1,180 @@
import type { PriceUnit } from '@/services/catalog/types';
import type { NurseGender, NurseReviewSnippet } from '../types';
/**
* Canned discovery fixtures for the client-side mock — real-shaped verified nurses so C1/C2/C3 demo
* before the backend join/profile endpoints land. Ids align with the sibling mocks so the end-to-end
* flow works with the geo picker + category grid: `serviceCategoryId` uses the catalog seed
* (1 = elderly, 2 = post-surgery, 3 = infant, 4 = chronic), `cityId`/`districtId` use the geography
* seed (Tehran = 101 with districts 1001…1022, Karaj = 801, whole-city = `null`). Mashhad/Isfahan/Shiraz
* are intentionally left with **no** nurses so the C2 "relax your filters" empty state is reachable.
*
* Every nurse here is verified + accepting by construction (the invariant the real index enforces), so
* the mock never returns an unverified row. `price` is IRR Rials as a digit-string (Toman × 10).
* Avatars are `null` on purpose (initials fallback) to keep the demo self-contained — no remote images.
*/
/** One priced, bookable offering of a seeded nurse, matched in a covered area. */
export interface SeedVariant {
variantId: number;
serviceCategoryId: number;
displayName: string;
priceIrr: string;
priceUnit: PriceUnit;
sessionCount: number | null;
cityId: number;
/** `null` = the nurse covers the whole city. */
districtId: number | null;
/** Approximate distance from the searched area (mock-only stand-in for a future geo-distance join). */
distanceKm: number | null;
}
/** A seeded verified nurse + their offerings and latest review (the mock's source of truth). */
export interface SeedNurse {
nurseId: number;
nurseName: string;
avatarUrl: string | null;
bio: string;
yearsExperience: number;
gender: NurseGender;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
inoMembership: boolean;
/** Specialty codes → i18n labels (never rendered raw). */
attributeChips: string[];
variants: SeedVariant[];
latestReview: NurseReviewSnippet | null;
}
export const SEED_NURSES: SeedNurse[] = [
{
nurseId: 1,
nurseName: 'مریم رضایی',
avatarUrl: null,
bio: 'پرستار سالمند با تمرکز بر مراقبت‌های شبانه‌روزی و پانسمان زخم.',
yearsExperience: 8,
gender: 'female',
averageRating: 4.9,
totalReviews: 37,
totalCompletedBookings: 52,
inoMembership: true,
attributeChips: ['elderly', 'wound_care'],
variants: [
{ variantId: 11, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2800000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 },
{ variantId: 12, serviceCategoryId: 1, displayName: 'مراقبت شبانه‌روزی سالمند', priceIrr: '85000000', priceUnit: 'per_24h', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 },
],
latestReview: {
rating: 5,
body: 'بسیار دلسوز و منظم بودند. مادرم کاملاً راضی بود.',
authorMasked: 'ز. م.',
createdAt: '2026-06-20T09:30:00Z',
},
},
{
nurseId: 2,
nurseName: 'سارا احمدی',
avatarUrl: null,
bio: 'پرستار مراقبت از سالمند و بیماری‌های مزمن، فعال در سراسر شهر تهران.',
yearsExperience: 6,
gender: 'female',
averageRating: 4.7,
totalReviews: 21,
totalCompletedBookings: 33,
inoMembership: true,
attributeChips: ['elderly', 'icu'],
variants: [
{ variantId: 21, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2500000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 },
{ variantId: 22, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '30000000', priceUnit: 'per_day', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 },
],
latestReview: {
rating: 5,
body: 'برخورد حرفه‌ای و به‌موقع. حتماً دوباره درخواست می‌دهم.',
authorMasked: 'م. ک.',
createdAt: '2026-06-28T14:10:00Z',
},
},
{
nurseId: 3,
nurseName: 'زهرا موسوی',
avatarUrl: null,
bio: 'متخصص مراقبت پس از جراحی و پانسمان تخصصی زخم.',
yearsExperience: 10,
gender: 'female',
averageRating: 4.8,
totalReviews: 44,
totalCompletedBookings: 61,
inoMembership: true,
attributeChips: ['post_surgery', 'wound_care'],
variants: [
{ variantId: 31, serviceCategoryId: 2, displayName: 'مراقبت پس از جراحی', priceIrr: '3200000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1005, distanceKm: 3.8 },
],
latestReview: {
rating: 4,
body: 'مراقبت خوبی داشتند، فقط کمی دیر رسیدند.',
authorMasked: 'ح. ر.',
createdAt: '2026-05-30T11:00:00Z',
},
},
{
nurseId: 4,
nurseName: 'علی کریمی',
avatarUrl: null,
bio: 'پرستار مراقبت‌های ویژه و مدیریت بیماری‌های مزمن.',
yearsExperience: 7,
gender: 'male',
averageRating: 4.6,
totalReviews: 18,
totalCompletedBookings: 27,
inoMembership: false,
attributeChips: ['icu'],
variants: [
{ variantId: 41, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '2900000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1002, distanceKm: 6.7 },
],
latestReview: {
rating: 5,
body: 'دقیق و مسئولیت‌پذیر. پیگیری داروها عالی بود.',
authorMasked: 'ع. ن.',
createdAt: '2026-06-15T08:45:00Z',
},
},
{
nurseId: 5,
nurseName: 'رضا حسینی',
avatarUrl: null,
bio: 'پرستار سالمند در کرج، فعال در تمام مناطق شهر.',
yearsExperience: 5,
gender: 'male',
averageRating: 4.5,
totalReviews: 12,
totalCompletedBookings: 19,
inoMembership: false,
attributeChips: ['elderly'],
variants: [
{ variantId: 51, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2200000', priceUnit: 'per_hour', sessionCount: null, cityId: 801, districtId: null, distanceKm: null },
],
latestReview: null,
},
{
nurseId: 6,
nurseName: 'فاطمه صادقی',
avatarUrl: null,
bio: 'پرستار نوزاد با تجربه در مراقبت روزانه و شبانه.',
yearsExperience: 9,
gender: 'female',
averageRating: 4.9,
totalReviews: 29,
totalCompletedBookings: 40,
inoMembership: true,
attributeChips: ['pediatric'],
variants: [
{ variantId: 61, serviceCategoryId: 3, displayName: 'مراقبت روزانه نوزاد', priceIrr: '3000000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1008, distanceKm: 4.2 },
],
latestReview: {
rating: 5,
body: 'با نوزاد ما فوق‌العاده مهربان بودند. بسیار حرفه‌ای.',
authorMasked: 'س. ط.',
createdAt: '2026-07-01T16:20:00Z',
},
},
];
+24
View File
@@ -0,0 +1,24 @@
/**
* When true, the search domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `SearchApi` seam. **Mock is primary this phase:** b7's search-index row and the b5/b6 reads do not
* yet expose the display name, avatar, distance, bio, specialties, full services list, or latest review
* that C2/C3 render (gap filed in `dev/shared-working-context/frontend/requests/for-backend.md`). The
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
*/
export const USE_SEARCH_MOCK = true;
/**
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
* within the stale window instead of refetching — the headline caching behaviour of this phase. A
* generous `gcTime` keeps prior filter sets warm so back/forward navigation is instant.
*/
export const SEARCH_RESULTS_STALE_TIME = 5 * 60 * 1000; // 5m
export const SEARCH_PROFILE_STALE_TIME = 5 * 60 * 1000; // 5m
export const SEARCH_GC_TIME = 30 * 60 * 1000; // 30m
/** api-conventions default/max page sizes (max 100 server-side); a page of result cards. */
export const SEARCH_PAGE_SIZE = 20;
/** Debounce window for the price-range inputs so keystrokes don't fan out one request per character. */
export const SEARCH_FILTER_DEBOUNCE_MS = 400;
@@ -0,0 +1,69 @@
import { PRICE_UNITS, type PriceUnit } from '@/services/catalog/types';
import { SEARCH_PAGE_SIZE } from './constants';
import type { NurseGender, NurseSearchFilters } from './types';
/**
* Single source of truth for the C1 → C2 filter **query string** (snake_case, matching the b7 contract
* params), so C1 (which writes the URL) and C2 (which reads it via `useSearchParams`) never drift. The
* URL is the deep-linkable, back/forward-safe carrier of the filter set; C2 turns it back into a
* `NurseSearchFilters`, which is what becomes the React Query cache key.
*/
/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
interface ParamReader {
get(name: string): string | null;
}
const GENDERS: readonly NurseGender[] = ['male', 'female'];
function parsePositiveInt(raw: string | null): number | undefined {
if (raw == null) return undefined;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : undefined;
}
function parseGender(raw: string | null): NurseGender | undefined {
return raw != null && GENDERS.includes(raw as NurseGender) ? (raw as NurseGender) : undefined;
}
function parsePriceUnit(raw: string | null): PriceUnit | undefined {
return raw != null && PRICE_UNITS.includes(raw as PriceUnit) ? (raw as PriceUnit) : undefined;
}
/** IRR digit-string or undefined (never a float; leaves bogus input out). */
function parseIrrString(raw: string | null): string | undefined {
return raw != null && /^\d+$/.test(raw) ? raw : undefined;
}
/** Serialise a filter set to snake_case URL params, omitting every absent optional filter. */
export function filtersToSearchParams(filters: NurseSearchFilters): URLSearchParams {
const params = new URLSearchParams();
params.set('service_category_id', String(filters.serviceCategoryId));
params.set('city_id', String(filters.cityId));
if (filters.districtId != null) params.set('district_id', String(filters.districtId));
if (filters.nurseGender) params.set('nurse_gender', filters.nurseGender);
if (filters.priceMin) params.set('min_price', filters.priceMin);
if (filters.priceMax) params.set('max_price', filters.priceMax);
if (filters.priceUnit) params.set('price_unit', filters.priceUnit);
return params;
}
/**
* Rebuild a `NurseSearchFilters` from URL params. `serviceCategoryId`/`cityId` fall back to `0` when
* absent/invalid — the query hook is disabled until both are `> 0`, so an incomplete URL is inert
* rather than an error. Sort is always rating (MVP); page/pageSize reset to the first page.
*/
export function searchParamsToFilters(params: ParamReader): NurseSearchFilters {
return {
serviceCategoryId: parsePositiveInt(params.get('service_category_id')) ?? 0,
cityId: parsePositiveInt(params.get('city_id')) ?? 0,
districtId: parsePositiveInt(params.get('district_id')),
nurseGender: parseGender(params.get('nurse_gender')),
priceMin: parseIrrString(params.get('min_price')),
priceMax: parseIrrString(params.get('max_price')),
priceUnit: parsePriceUnit(params.get('price_unit')),
sort: 'rating',
page: 1,
pageSize: SEARCH_PAGE_SIZE,
};
}
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
/**
* Returns a debounced copy of `value` that only updates after `delayMs` of no changes. Used by the C1
* filter controller for the price-range inputs so typing doesn't fan out one search request per
* keystroke (phase §5 "Debounce input") — the debounced value is what becomes part of the query key.
*/
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { searchApi } from '../apis';
import { searchKeys } from '../keys';
import { SEARCH_GC_TIME, SEARCH_PROFILE_STALE_TIME } from '../constants';
/**
* The C3 nurse-profile query, keyed on `searchKeys.profile(nurseId)` and enabled only when an id is
* present. Cached for the stale window so returning from the booking handoff serves from cache.
*/
export function useNurseProfile(nurseId: number | undefined) {
return useQuery({
queryKey: searchKeys.profile(nurseId ?? -1),
queryFn: () => searchApi.getNurseProfile(nurseId as number),
enabled: nurseId != null,
staleTime: SEARCH_PROFILE_STALE_TIME,
gcTime: SEARCH_GC_TIME,
});
}
@@ -0,0 +1,23 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { searchApi } from '../apis';
import { searchKeys } from '../keys';
import { SEARCH_GC_TIME, SEARCH_RESULTS_STALE_TIME } from '../constants';
import type { NurseSearchFilters } from '../types';
/**
* The C2 discovery query. **The filter object is the query key** (`searchKeys.results`), so an
* identical filter set is served straight from cache — changing a filter and reverting to a previous
* set is a cache hit with zero network calls. `placeholderData: keepPreviousData` keeps the previous
* page/results on screen while a new filter loads, so the list never flashes empty. Enabled only once
* the two required facets (category + city) are chosen.
*/
export function useNurseSearch(filters: NurseSearchFilters) {
return useQuery({
queryKey: searchKeys.results(filters),
queryFn: () => searchApi.searchNurses(filters),
enabled: filters.serviceCategoryId > 0 && filters.cityId > 0,
staleTime: SEARCH_RESULTS_STALE_TIME,
gcTime: SEARCH_GC_TIME,
placeholderData: keepPreviousData,
});
}
+3
View File
@@ -0,0 +1,3 @@
export { useNurseSearch } from './hooks/useNurseSearch';
export { useNurseProfile } from './hooks/useNurseProfile';
export { useDebouncedValue } from './hooks/useDebouncedValue';
+37
View File
@@ -0,0 +1,37 @@
import type { NurseSearchFilters } from './types';
/**
* React Query key factory for the search domain.
*
* **The filter object IS the query key** (phase §5, the caching contract). `results(filters)` keys on a
* **canonical** serialization of the full filter object — a stable key order with every *absent* optional
* filter omitted (never carried as `undefined`). Two filter sets that are semantically equal therefore
* produce the identical key, so changing a filter and **reverting** to a previous set is a cache hit with
* zero network calls (React Query hashes query keys deterministically; canonicalizing here makes the
* intent explicit and keeps the URL/query-param serialization aligned with the cache key).
*/
/** Canonical, order-stable filter object with absent optionals omitted (the cache key + query params). */
export function canonicalizeSearchFilters(filters: NurseSearchFilters): Record<string, string | number> {
const canonical: Record<string, string | number> = {
serviceCategoryId: filters.serviceCategoryId,
cityId: filters.cityId,
sort: filters.sort,
page: filters.page,
pageSize: filters.pageSize,
};
if (filters.districtId != null) canonical.districtId = filters.districtId;
if (filters.nurseGender != null) canonical.nurseGender = filters.nurseGender;
if (filters.priceMin != null && filters.priceMin !== '') canonical.priceMin = filters.priceMin;
if (filters.priceMax != null && filters.priceMax !== '') canonical.priceMax = filters.priceMax;
if (filters.priceUnit != null) canonical.priceUnit = filters.priceUnit;
return canonical;
}
export const searchKeys = {
all: ['search'] as const,
results: (filters: NurseSearchFilters) =>
[...searchKeys.all, 'results', canonicalizeSearchFilters(filters)] as const,
profiles: () => [...searchKeys.all, 'profile'] as const,
profile: (nurseId: number) => [...searchKeys.profiles(), nurseId] as const,
};
+129
View File
@@ -0,0 +1,129 @@
import type { Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
/**
* Search & discovery domain — the family-facing nurse-finding layer. Shapes are derived from the b7
* contract (`dev/contracts/domains/search.md`) plus the b6 trust badge / b5 variant reads for the
* profile. The wire is **camelCase** and `clientFetch` unwraps the `ApiResult<T>` envelope, so these
* are the post-`unwrap()` payloads.
*
* Load-bearing semantics (see the contract "Key semantics" + phase §5):
* - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit
* only when the nurse is verified + not suspended + accepting + the variant is active. The UI must
* **never** re-filter for verification, and never surface an unverified/paused nurse.
* - **The result unit is the variant, not the nurse** — a nurse with several variants/areas can appear
* as several hits.
* - **`districtId = null` ⇒ whole city**, both directions; the client omits `districtId` for a
* whole-city search rather than sending a bogus value.
* - **Same-gender is first-class** — `nurseGender` is an up-front filter, never silently defaulted or
* dropped, and the chosen value is carried into the booking request as `required_caregiver_gender`
* (f7), surfaced *before* booking.
* - **Money is an IRR digit-string** (`price`) — rendered only via the money util, never parsed to a float.
* - **Rating sort only (MVP).**
*
* @remarks b7's `NurseSearchResultDto` and the b5/b6 reads do **not** yet expose the nurse's display
* name, avatar, distance, bio, specialties, full services list, or latest review that C2/C3 render. Those
* gaps are served by the in-memory mock (`apis/mockApi.ts`, primary this phase) and filed for the backend
* in `dev/shared-working-context/frontend/requests/for-backend.md`; the real client
* (`apis/clientApi.ts`) maps what b7/b6/b5 currently provide and is swapped in when the endpoints land.
*/
/** A caregiver's gender — the same-gender matching facet (`any` is expressed by omitting the filter). */
export type NurseGender = 'male' | 'female';
/** The only MVP result ordering. Rendered as a control with one option; other sorts are DEFERRED. */
export type SearchSort = 'rating';
/** The filter object — this **is** the React Query cache key (see `keys.ts`) and the C2 query string. */
export interface NurseSearchFilters {
serviceCategoryId: number;
cityId: number;
/** Omit for a whole-city search; "empty district = whole city" (never send a bogus district). */
districtId?: number;
/** Omit = فرقی ندارد / any gender. Never defaulted silently. */
nurseGender?: NurseGender;
/** Inclusive IRR-Rial digit-string bounds; compared like-for-like within a `priceUnit`. */
priceMin?: string;
priceMax?: string;
/** Compare only like-for-like listings (e.g. only `per_hour`). */
priceUnit?: PriceUnit;
sort: SearchSort;
page: number;
pageSize: number;
}
/** A single C2 result card row (one bookable variant matched in a covered area). */
export interface NurseSearchResult {
nurseId: number;
variantId: number;
serviceCategoryId: number;
/** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */
nurseName: string;
avatarUrl: string | null;
/** Always `true` by the search-index invariant — the UI relies on this, never re-checks it. */
isVerified: boolean;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
/** Kilometres from the searched area; `null` when unknown — the card hides the distance chip. */
distanceKm: number | null;
/** The variant's `price` as an IRR-Rial digit-string; rendered via the money util only. */
priceFromIrr: string;
priceUnit: PriceUnit;
nurseGender: NurseGender;
cityId: number;
/** `null` = the nurse covers the whole city. */
districtId: number | null;
}
/** One offered variant on the C3 profile — the bookable unit; reused by the ServicePriceRow. */
export interface NurseProfileServiceRow {
variantId: number;
displayName: string;
/** IRR-Rial digit-string; rendered via the money util + the localized `priceUnit` label. */
priceIrr: string;
priceUnit: PriceUnit;
sessionCount?: number | null;
}
/** A short latest-review snippet for C3 (the full reviews tab is DEFERRED → f13). */
export interface NurseReviewSnippet {
rating: number;
body: string;
/** Author name already masked server-side (PII rule); rendered verbatim. */
authorMasked: string;
/** UTC ISO-8601; displayed via the Shamsi date util. */
createdAt: string;
}
/** The C3 nurse-profile payload. */
export interface NurseProfile {
nurseId: number;
nurseName: string;
avatarUrl: string | null;
bio: string | null;
yearsExperience: number | null;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
/** Always `true` for a discoverable nurse (invariant); drives the ✓ تاییدشده badge. */
isVerified: boolean;
/** نظام پرستاری (INO membership) — render the badge only when `true`. */
inoMembership: boolean;
/** Specialty **codes** (mapped to i18n labels, never rendered raw); the C3 attribute chips. */
attributeChips: string[];
services: NurseProfileServiceRow[];
latestReview?: NurseReviewSnippet | null;
nurseGender: NurseGender;
}
/**
* The search domain's API seam — the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_SEARCH_MOCK`), never scattered `if (mock)` checks.
*/
export interface SearchApi {
/** The single family-facing discovery query over the maintained search index. */
searchNurses(filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>>;
/** The C3 nurse profile (identity + badges + services + latest review). */
getNurseProfile(nurseId: number): Promise<NurseProfile>;
}