# Frontend Phase 6 — Search & discovery (find a verified, same-gender nurse) > **Mission:** build the family-facing **discovery** experience — the heart of the marketplace. A > customer picks a care category, narrows by city / gender / price, and gets a rating-sorted list of > **only verified, accepting** nurses; opening one shows their trust badges, attribute chips, priced > services, and latest review, ending in the **"درخواست رزرو"** call-to-action that hands off to the > booking flow. This phase implements wireframe screens **C1 (search & filter)**, **C2 (results)**, > and **C3 (nurse profile)** against the `search` domain (backend phase b7), and establishes the shared > **nurse-result card** + **price-row** components that the booking flow will reuse. > > **Track:** frontend · **Depends on:** [frontend-phase-4-b5](./frontend-phase-4-b5.md) (catalog browse > & service builder) · [frontend-phase-5-b6](./frontend-phase-5-b6.md) (verified-nurse / trust badge) · > backend **b7** contract ([`dev/contracts/domains/search.md`](../../contracts/domains/search.md)) · > **Unlocks:** [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow) > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits We are at the pivot of the customer journey: everything before this phase let a family *enter* the app (auth f1), describe *who needs care* (onboarding f2), say *where* (addresses f3), and *browse the catalog* (f4). This phase is the first time a family sees **real nurses** and chooses one. It is the screen the product calls the trust funnel: discovery surfaces **only platform-vetted, same-gender-filterable** caregivers, which is Balinyaar's entire differentiation versus opaque incumbents. The output of this phase — a selected nurse + the carried filter intent (especially `required_caregiver_gender`) — is the input to the booking request (f7). **What already exists (do not rebuild) — link, extend, never re-create:** - **f0 foundations** ([frontend-phase-0](./frontend-phase-0.md)): the customer mobile shell with the 5-tab bottom nav (خانه/رزروها/بیماران/کیف‌پول/پروفایل), the `services/{domain}` + TanStack Query caching pattern (`keys.ts` factory, `apis/clientApi.ts`, one-hook-per-file, mutation invalidation), the **money/format util** (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) in `src/utils/`, the i18n namespace conventions (incl. the `search` namespace), the RTL baseline, and the shared composite primitives (status chip, stepper, etc.). **Copy the `auth` service shape — do not invent a new data pattern.** - **f4 catalog** ([frontend-phase-4-b5](./frontend-phase-4-b5.md)): the `catalog` service + the **category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) and category cards used on the customer Home (A5). C1 **reuses that category grid component** and the catalog query — do not rebuild category fetching here. - **f5 verified-nurses** ([frontend-phase-5-b6](./frontend-phase-5-b6.md)): the **trust badge** component(s) — ✓ تاییدشده (verified) and نظام پرستاری (INO membership) — and the verification-status vocabulary. C2/C3 **reuse those badges**; do not re-implement the verified mark. - **Money/format:** prices render through the f0 `formatIrrToToman` util (IRR Rials string → Toman display). Never format money inline. > **Data note:** b7's `GET /search/nurses` reads the denormalized `nurse_search_index`, which by > invariant contains a row **only** when the nurse is `is_verified` AND not suspended AND > `is_accepting_bookings` AND the variant `is_active`. So *every* result you receive is already > bookable — the UI must not need to re-filter for verification. (See §5.) ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md). - [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary, the `[locale]` layout rule, i18n, theme/tokens, `clientFetch`/`serverFetch`, the `services/{domain}` layout, TanStack Query setup, the cookie manager). Don't break the boundary. - **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract (palette: teal `#1d4a40`, terracotta `#d98c6a`, cream; tokens; typography; the `App*` library; the mobile shell; the hard UI rules). C1/C2/C3 must come out branded, RTL, dark-mode-ready. **All visual work goes through it.** - [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — Section **C** (screens **C1**, **C2**, **C3**): the exact layout, copy, and controls you implement. C1 = selected category + filter pills (تهران/location, تاریخ/date, جنسیت/gender) + category grid + "مشاهده ۲۴ پرستار" results CTA; C2 = result count + "مرتب‌سازی: امتیاز" sort + nurse cards (photo, name, ✓ تاییدشده, rating/review count, distance km, "from X تومان/ساعت"); C3 = avatar, name, rating, verified + نظام پرستاری badges, attribute chips, services-and-prices rows, latest review snippet, "درخواست رزرو". - [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) — the business rules: category + city/(optional district) geo search, rating sort, and the **same-gender caregiver** near-hard requirement (`nurse_gender` filter + the requested `required_caregiver_gender` carried *before* booking, not after). - **The contract** [`../../contracts/domains/search.md`](../../contracts/domains/search.md) (b7) + [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) and [`api-conventions.md`](../../contracts/conventions/api-conventions.md) — the envelope, the IRR-as-string money rule, `gender` = `male`/`female`/`any`, `price_unit` = `per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`, enums-as-codes (labels are i18n keys, never derived from the code), pagination params, and the exact request/response shapes for `GET /search/nurses` and the nurse-profile/variant payloads. **Types come from this doc — do not guess server shapes** (if a shape is missing, follow §4 + operating-rules §6). - The backend handoff [`../../shared-working-context/backend/handoff/after-backend-phase-7.md`](../../shared-working-context/backend/handoff/after-backend-phase-7.md) — which endpoints are live, what's mocked, what to consume. - The existing **`src/services/auth/*`** (the template) and the **f4 `catalog`** + **f5 trust-badge** code — the patterns you copy and the components you reuse. ## 3. Scope — build this A vertical slice: `services/search` (types/keys/apis/hooks) → the three screens (C1, C2, C3) → the two shared components (nurse-result card, price-row), all RTL/i18n/cache-correct. ### 3.1 The `search` domain service (`src/services/search/`) Copy the f0/`auth` service shape exactly: - **`types.ts`** — mirror the b7 contract as string-literal unions / interfaces (don't guess): - `NurseSearchFilters` — `serviceCategoryId: number`, `cityId: number`, `districtId?: number`, `nurseGender?: 'male' | 'female'` (omit = any), `priceMin?: string`, `priceMax?: string` (IRR strings), `sort: 'rating'` (the only MVP sort), `page`, `pageSize`. - `NurseSearchResult` — the C2 card row: `nurseId`, `variantId`, `displayName`/`nurseName`, `avatarUrl`, `isVerified` (always true by invariant), `averageRating`, `totalReviews`, `distanceKm?`, `priceFromIrr` (string), `priceUnit` (the `per_*` union), `nurseGender`. - `NurseProfile` — the C3 payload: identity (`nurseName`, `avatarUrl`, `bio`, `yearsExperience`), `averageRating`/`totalReviews`/`totalCompletedBookings`, `isVerified`, `inoMembership` (نظام پرستاری badge flag), `attributeChips` (specialties/تخصص‌ها labels), `services: NurseProfileServiceRow[]` (each = `variantId`, `displayName`, `priceIrr` string, `priceUnit`, optional `sessionCount`), and a `latestReview?` snippet (`rating`, `body`, `authorMasked`, `createdAt`). - `Paged` — reuse the f0 paginated envelope type. - **`keys.ts`** — the query-key factory: `searchKeys.results(filters)` keyed on the **full filter object** (this is the cache contract — see §5), `searchKeys.profile(nurseId)`. - **`apis/clientApi.ts`** — wraps `clientFetch` (never raw `fetch`): - `searchNurses(filters): Promise>` → `GET /search/nurses` (filters as query params; omit absent optional filters so the key/URL stay stable). - `getNurseProfile(nurseId): Promise` → the b7 nurse-profile endpoint (consume the exact route from the contract). - Add a **`serverApi.ts`** only if you prefetch C2/C3 from an RSC to remove a client round-trip (optional, see §5). - **`hooks/` (one hook per file):** - `useNurseSearch.ts` — `useQuery({ queryKey: searchKeys.results(filters), queryFn })` with a deliberate `staleTime` (results are read-heavy and change slowly) and `keepPreviousData` so the list doesn't flash empty while a new filter loads. - `useNurseProfile.ts` — `useQuery` on `searchKeys.profile(nurseId)`; `enabled` only when an id is present. - `useDebouncedSearchFilters.ts` (or fold the debounce into the C1 controller) — **debounce the free-text/quick-filter input** so keystrokes don't fan out one request per character. - **`index.ts`** — barrel. ### 3.2 C1 — Search & filter screen (`جستجو و فیلتر`) The entry screen (reachable from Home/A5 search bar and category tap). Build: - **Selected-category** header + the **category grid** (reuse the f4 catalog category grid; selecting a category sets `serviceCategoryId`). Categories shown per wireframe: مراقبت سالمند، پرستار کودک، تزریقات و سرم، مراقبت زخم (driven by the live catalog, not hardcoded labels). - **Filter pills** (the C1 row): **city** (تهران ▾ — required; the cascading province→city→district picker reused from f3 geo — district optional, "leaving district empty searches the whole city" helper copy), **date** (تاریخ ▾ — capture intent only; availability is **soft** at MVP and is *not* a hard search filter — pass it through to booking, don't filter results on it), **gender** (جنسیت ▾ — خانم / آقا / فرقی ندارد → `male`/`female`/omit). Make **gender a prominent, early, first-class control** with one line of copy on why same-gender matters for bodily care. - An optional **price-range** control (min/max → IRR strings) and the **results CTA** that mirrors the wireframe's "مشاهده ۲۴ پرستار" — i.e. show the live result **count** and navigate to C2. (Wire the count off a lightweight `useNurseSearch` head/`totalCount`, or navigate and show the count on C2 — your call, but the number must be real, not hardcoded.) - Hold filter state in a small **colocated controller** (a `useSearchFilters` hook or local reducer) — *not* in a high context provider (it changes fast). The filter object is what becomes the query key. ### 3.3 C2 — Results screen (`نتایج جستجو`) - **Header:** result **count** ("۲۴ پرستار") + **sort control** "مرتب‌سازی: امتیاز ▾" (rating is the only MVP sort — render it as a control but it has one option; tag other sorts **(DEFERRED)**). - **List** of **`NurseResultCard`** (§3.5) rendered from `useNurseSearch(filters)`, paginated (infinite scroll or a "load more" — reuse the f0 paginated pattern). The customer **bottom tab nav** stays visible (this is a shell screen). - **States (all required):** **loading** = skeleton cards (not a spinner); **empty** = the "no nurses match → relax your filters" state with concrete suggestions (remove the gender filter / widen to whole city by clearing district / try a nearby city — lean on the white-space cities Mashhad/Isfahan/Shiraz); **error** = retry; **populated** = rating-sorted cards. Tapping a card → C3. - Changing a filter on C1 and returning re-queries with the new key; **reverting to a prior filter set reuses the cached result** (no refetch) — this is an acceptance criterion (§5, §7). ### 3.4 C3 — Nurse profile screen (`پروفایل پرستار`) From `useNurseProfile(nurseId)`: - **Header:** avatar, name, **rating** (+ review count), and **badges** — ✓ تاییدشده (reuse f5 verified badge) and **نظام پرستاری** (INO membership; render only when `inoMembership` is true). - **Attribute chips:** specialties / experience (سالمندان، تزریقات، ۸ سال سابقه) from `attributeChips` / `yearsExperience`. - **Services & prices:** a list of **`ServicePriceRow`** (§3.5) — one row per offered variant (`displayName` + price rendered via `formatIrrToToman` + the localized `price_unit` label, e.g. "۲۸۰٬۰۰۰ تومان/ساعت", and a 12h night-shift variant). These are the bookable units. - **Latest review snippet** (`latestReview`) — rating + masked author + truncated body; "no reviews yet" empty state. (The full reviews tab is **(DEFERRED)** → [frontend-phase-13-b14](./frontend-phase-13-b14.md).) - **Primary CTA: "درخواست رزرو"** — navigates to the booking request form (f7), **carrying the selected nurse + variant + the filter intent** (especially `required_caregiver_gender` derived from the C1 gender filter, and the city/category). f7 owns the form; this phase only hands off the intent — pass it via route params / a small handoff, do **not** build the request form here. Tag the form itself **(DEFERRED → f7)**. - States: loading skeleton, not-found (404 → "this nurse is no longer available"), error/retry. ### 3.5 Shared components (built once, reused by f7+) At the shared level (`src/components/…`), composed from MUI/`App*` primitives (never re-implement a root primitive), each with a **co-located `*.test.tsx`** and i18n in both locales: - **`NurseResultCard`** — the C2 card: `Avatar` (photo), name, the reused **verified badge**, rating + review count, distance chip (km, only when `distanceKm` present), and the **"from X تومان/ساعت"** price line (via `formatIrrToToman` + localized unit). Pure/presentational, memoized, stable props so a list of N cards doesn't re-render on unrelated state. - **`ServicePriceRow`** — the C3 service line and a reusable price row: localized service name + the money formatting + the `price_unit` label. Reused on C3 now and by the booking summary later. > These two are the load-bearing reusable pieces. The category grid (f4), trust badges (f5), geo picker > (f3), and status chip (f0) are **reused**, not rebuilt. ### 3.6 i18n Fill the **`search`** namespace (seeded in f0) in **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first: filter labels (شهر/تاریخ/جنسیت/خانم/آقا/فرقی ندارد), sort label, result-count pluralization, every empty/error/loading copy, badge labels (تاییدشده/نظام پرستاری), price-unit labels (ساعتی/per_hour … per_24h), and the "درخواست رزرو" CTA. **No display label is derived from an enum code** — each `price_unit`/`gender`/sort code maps to an i18n key. ## 4. Mocks & seams in this phase This is a **frontend** phase — it introduces **no backend seam**; it **consumes** the b7 contract. - **Reuse the `services/{domain}` seam pattern from f0.** All data goes through `clientFetch` inside `services/search/apis/`. - **If b7 is not yet merged (or a needed shape is missing):** build a **mock `clientApi`** behind the *same* `services/search` seam (real-shaped fixtures: a handful of verified nurses with ratings, distances, prices, badges; one profile with services + a review) so C1/C2/C3 are fully demoable, **and** (a) append the exact missing/mismatched shape to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) (operating-rules §6 — you never edit backend files), and (b) record the mock in your frontend report so it's swapped out cleanly when the real endpoint lands. Selection between mock and real `clientApi` is by the seam (one import swap), never an `if (mock)` scattered through components. No new entry is needed in the backend `mocks-registry.md` (that registry is for backend DI seams); the client-side mock is recorded in your **frontend report** instead. ## 5. Critical rules you must not get wrong - **Only verified + accepting nurses appear — and the UI must not have to enforce it.** The b7 `nurse_search_index` invariant guarantees every returned row is verified, not suspended, accepting, and on an active variant. **Never** add client logic that *re-includes* hidden nurses, and never display an unverified/paused nurse. If a result somehow lacks the verified flag, treat it as a data defect and file it via `for-backend.md` — do not paper over it. - **The filter object IS the query key — identical filters reuse cache, never refetch.** `queryKey = searchKeys.results(filters)` must be a *stable, canonical* serialization (sorted keys, omitted optional filters rather than `undefined`, IRR as strings). Changing a filter and **reverting** to a previous set must hit the React Query cache with **zero** network calls (verify in Devtools — §7). Use `keepPreviousData` so the list doesn't flash. This is the whole point of the phase's caching design. - **Debounce input.** Free-text / quick-filter typing must **not** fire one request per keystroke — debounce before it becomes part of the query key. - **Same-gender is first-class and carried *before* booking.** The gender filter (`male`/`female`/`any`) is a prominent, early control with explanatory copy; the chosen value is carried into the booking handoff as `required_caregiver_gender` (f7) — surfaced *before* booking, never discovered after. Never default or silently drop gender. - **Geography semantics:** city is required; **district is optional and "empty district = whole city"** — the picker copy must say so; don't send a bogus district. (Backend matches city-only + district rows; the client just leaves `districtId` absent.) - **Availability is soft at MVP — never a hard search filter.** The date pill captures intent for the booking flow; it must not remove nurses from results. Tag availability-window filtering **(DEFERRED)**. - **Money renders through the f0 util only — IRR Rials are integer strings, no floats, Toman is display-only.** Format with `formatIrrToToman`; never parse IRR into a JS number for math, never compute a "from" price client-side beyond picking the min the server sent. - **Enums are codes; labels are i18n keys.** `price_unit`, `gender`, and sort never render their raw code; each maps to a localized label in both `en.json`/`fa.json`. - **RSC/client boundary + caching discipline:** prefetch C2/C3 from an RSC with `initialData` only if it removes a round-trip; otherwise client-fetch. No `next/headers`/`next-intl/server` in client components. - **Minimal re-renders:** `NurseResultCard` is presentational/memoized; keep fast-changing filter state colocated (not in a high provider); use `select` to subscribe to slices where it helps. - **MUI primitives stay MUI; the two new composites live shared** — not inline in a page. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] `services/search` exists (`types`/`keys`/`apis`/`hooks`/`index`), copying the f0 pattern; types derive from the b7 contract (or a mock behind the seam + a `for-backend.md` request if b7 isn't ready). - [ ] **C1, C2, C3** are built per the wireframe, RTL, with category grid (reused), city/gender/(optional district)/price filters, rating sort, and the prominent same-gender control. - [ ] C2 has **all four states** (loading skeletons / empty "relax filters" / error-retry / populated); C3 has loading / not-found / error states. - [ ] **Caching proven:** the filter object is the query key; reverting a filter reuses cache with no network call; input is debounced; `keepPreviousData` set. (Demonstrable in Devtools — §7.) - [ ] `NurseResultCard` + `ServicePriceRow` are **shared** components with co-located tests; the verified badge (f5), category grid (f4), geo picker (f3) are reused, not rebuilt. - [ ] Prices render via the f0 money util; every string is an i18n key in **both** locales, in sync; no label derived from an enum code. - [ ] "درخواست رزرو" hands off the selected nurse + variant + `required_caregiver_gender` + city/category to the f7 route (form itself deferred to f7). - [ ] `npm run check` green; `npm run test:ci` green (the two new shared components are covered); `client/CLAUDE.md` *Project Structure* updated for the new `services/search`, the two shared components, and the C1/C2/C3 routes. ## 7. How to test (what a human can verify after this phase) Run `npm run dev` (point `NEXT_PUBLIC_API_URL` at a b7-enabled server, or use the seam mock if b7 isn't merged): - **End-to-end discovery:** from Home, open **C1** → pick a category (e.g. مراقبت سالمند), set city (تهران), set gender (خانم) → the results CTA shows a real count → tap it → **C2** lists **only verified** nurses, rating-sorted, each card showing photo, name, ✓ تاییدشده badge, rating + review count, distance, and "from X تومان/ساعت". Confirm no unverified/paused nurse ever appears. - **Profile:** tap a card → **C3** shows avatar, rating, ✓ تاییدشده + نظام پرستاری badges, attribute chips, the services-and-prices rows (correct Toman formatting + Persian unit labels), and the latest review snippet; **"درخواست رزرو"** navigates to the f7 route carrying nurse + variant + gender intent. - **Empty state:** search a white-space city/category/gender combo with no matches → the "no nurses match → relax your filters" state with concrete suggestions (clear district / drop gender / try Mashhad). - **Caching (the headline check):** open React Query Devtools → apply filter set A (cache entry A) → change to set B (entry B, one fetch) → **revert to A** → the list shows instantly with **zero new network requests** (cache hit on key A). Type quickly in the search input → confirm **one** debounced request, not one per keystroke. - **i18n/RTL:** flip locale fa↔en → all C1/C2/C3 labels, badges, unit labels, and empty/error copy translate and mirror correctly; dark mode still renders. - **Gate:** `npm run check` and `npm run test:ci` pass. ## 8. Hand off & document (close the phase) - **Docs:** update the **Project Structure** tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) for the new `services/search`, the C1/C2/C3 routes/segments, and the two new shared components (`NurseResultCard`, `ServicePriceRow`); note the "filter-object-as-query-key" caching pattern as a reusable convention. Fix any doc drift you touch. - **Contracts:** **consume** [`../../contracts/domains/search.md`](../../contracts/domains/search.md) (b7) — derive `services/search/types.ts` from it; **do not** edit it. Any missing/ambiguous shape (e.g. the nurse-profile services array, the `latestReview` shape, distance units, or the count head) goes to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) as an append — the backend delivers it in a later change; you never edit backend files. - **Handoff & report:** append your summary to [`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write [`../../shared-working-context/reports/frontend-phase-6-report.md`](../../shared-working-context/reports/frontend-phase-6-report.md) — what was built (C1/C2/C3 + `services/search` + the two shared components), **what is now testable and exactly how** (the steps in §7), what (if anything) is **mocked behind the `services/search` seam** and how f-next swaps it for the real b7 endpoint, the contract consumed + any `for-backend` requests filed, and the follow-ups (the f7 booking handoff contract for the carried intent; the C3 reviews tab deferred to f13). - **Memory:** save a `project`-type memory note for the non-obvious decisions this phase locks in — the **filter-object-as-query-key** caching contract, the verified-only invariant the UI relies on (so a future agent doesn't add a client-side verification re-filter), and the **`required_caregiver_gender` carried-before-booking** handoff to f7 — with a one-line pointer added to `MEMORY.md`.