25 KiB
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
searchdomain (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 (catalog browse & service builder) · frontend-phase-5-b6 (verified-nurse / trust badge) · backend b7 contract (
dev/contracts/domains/search.md) · Unlocks: frontend-phase-7-b8 (booking request flow) Before you start, read../_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): the customer mobile shell with the
5-tab bottom nav (خانه/رزروها/بیماران/کیفپول/پروفایل), the
services/{domain}+ TanStack Query caching pattern (keys.tsfactory,apis/clientApi.ts, one-hook-per-file, mutation invalidation), the money/format util (formatIrrToToman, integer-safe IRR-string parse, Shamsi date display) insrc/utils/, the i18n namespace conventions (incl. thesearchnamespace), the RTL baseline, and the shared composite primitives (status chip, stepper, etc.). Copy theauthservice shape — do not invent a new data pattern. - f4 catalog (frontend-phase-4-b5): the
catalogservice + 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): 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
formatIrrToTomanutil (IRR Rials string → Toman display). Never format money inline.
Data note: b7's
GET /search/nursesreads the denormalizednurse_search_index, which by invariant contains a row only when the nurse isis_verifiedAND not suspended ANDis_accepting_bookingsAND the variantis_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.mdand../_shared/frontend-conventions-checklist.md.../../../client/CLAUDE.md— the engineering contract (RSC/client boundary, the[locale]layout rule, i18n, theme/tokens,clientFetch/serverFetch, theservices/{domain}layout, TanStack Query setup, the cookie manager). Don't break the boundary.- Invoke the
frontend-designerskill before any visual work — it is the design/brand contract (palette: teal#1d4a40, terracotta#d98c6a, cream; tokens; typography; theApp*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— 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— the business rules: category + city/(optional district) geo search, rating sort, and the same-gender caregiver near-hard requirement (nurse_genderfilter + the requestedrequired_caregiver_gendercarried before booking, not after).- The contract
../../contracts/domains/search.md(b7) +../../contracts/conventions/money-and-types.mdandapi-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 forGET /search/nursesand 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— which endpoints are live, what's mocked, what to consume. - The existing
src/services/auth/*(the template) and the f4catalog+ 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(theper_*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,priceIrrstring,priceUnit, optionalsessionCount), and alatestReview?snippet (rating,body,authorMasked,createdAt).Paged<T>— 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— wrapsclientFetch(never rawfetch):searchNurses(filters): Promise<Paged<NurseSearchResult>>→GET /search/nurses(filters as query params; omit absent optional filters so the key/URL stay stable).getNurseProfile(nurseId): Promise<NurseProfile>→ the b7 nurse-profile endpoint (consume the exact route from the contract).- Add a
serverApi.tsonly 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 deliberatestaleTime(results are read-heavy and change slowly) andkeepPreviousDataso the list doesn't flash empty while a new filter loads.useNurseProfile.ts—useQueryonsearchKeys.profile(nurseId);enabledonly 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
useNurseSearchhead/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
useSearchFiltershook 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 fromuseNurseSearch(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
inoMembershipis true). - Attribute chips: specialties / experience (سالمندان، تزریقات، ۸ سال سابقه) from
attributeChips/yearsExperience. - Services & prices: a list of
ServicePriceRow(§3.5) — one row per offered variant (displayName+ price rendered viaformatIrrToToman+ the localizedprice_unitlabel, 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.) - Primary CTA: "درخواست رزرو" — navigates to the booking request form (f7), carrying the selected
nurse + variant + the filter intent (especially
required_caregiver_genderderived 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 whendistanceKmpresent), and the "from X تومان/ساعت" price line (viaformatIrrToToman+ 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 + theprice_unitlabel. 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 throughclientFetchinsideservices/search/apis/. - If b7 is not yet merged (or a needed shape is missing): build a mock
clientApibehind the sameservices/searchseam (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(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 realclientApiis by the seam (one import swap), never anif (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_indexinvariant 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 viafor-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 thanundefined, 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). UsekeepPreviousDataso 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 asrequired_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
districtIdabsent.) - 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 bothen.json/fa.json. - RSC/client boundary + caching discipline: prefetch C2/C3 from an RSC with
initialDataonly if it removes a round-trip; otherwise client-fetch. Nonext/headers/next-intl/serverin client components. - Minimal re-renders:
NurseResultCardis presentational/memoized; keep fast-changing filter state colocated (not in a high provider); useselectto 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, plus:
services/searchexists (types/keys/apis/hooks/index), copying the f0 pattern; types derive from the b7 contract (or a mock behind the seam + afor-backend.mdrequest 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;
keepPreviousDataset. (Demonstrable in Devtools — §7.) NurseResultCard+ServicePriceRoware 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 checkgreen;npm run test:cigreen (the two new shared components are covered);client/CLAUDE.mdProject Structure updated for the newservices/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 checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs: update the Project Structure tree in
../../../client/CLAUDE.mdfor the newservices/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(b7) — deriveservices/search/types.tsfrom it; do not edit it. Any missing/ambiguous shape (e.g. the nurse-profile services array, thelatestReviewshape, distance units, or the count head) goes to../../shared-working-context/frontend/requests/for-backend.mdas 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; write../../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 theservices/searchseam and how f-next swaps it for the real b7 endpoint, the contract consumed + anyfor-backendrequests 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 therequired_caregiver_gendercarried-before-booking handoff to f7 — with a one-line pointer added toMEMORY.md.