24 KiB
UI Phase 4 — Customer storefront
Mission: turn home → search → results → nurse profile from functional screens into a storefront that answers "would I let this person into my mother's home?". The data layer already fetches the trust signals (gender, completed visits, verification badge, ratings) — the UI drops most of them on the floor, ships a dead search bar, a fake sort control, a Gregorian date picker in a Shamsi product, and a primary CTA buried under an infinite review list. This phase fixes the funnel's honesty defects and builds the reusable trust-presentation components (
VerificationPanel, the tappableTrustBadgeexplainer) that phase 8's public-profile preview reuses.Track: frontend · Depends on: Phases 0–2 · Unlocks: Phase 5 (booking funnel) + Phase 8 (reuses the trust components built here) Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke the frontend-designer skill — both are mandatory.
1. Context — where this sits
The storefront is the four customer discovery screens: client/src/app/[locale]/(private-routes)/(customer)/page.tsx
(A5 home), search/page.tsx (C1 filters), search/results/page.tsx (C2 results), and
search/nurse/[nurseId]/page.tsx (C3 profile). Phases 0–2 already de-startered the theme, primitives, and chrome —
this phase redesigns the route tree itself. Diagnosed defects (all verified in code, full evidence in
audit/customer-storefront.md):
- The home search bar is a dead affordance.
HomeSearchBarpushes?q=<query>(page.tsx L130) butSearchFilterScreenreads onlycategory_id(search/page.tsx L49–50) and silently discardsq. The placeholder promises «جستجوی خدمت یا پرستار…» and the input does nothing. - A failed patients query bricks the home forever. The gate
if (data == null || isEmpty) return <AppLoading />(page.tsx L66–68) has noisError/retry branch — a transient API failure leaves the app's front door on a spinner. - The patient-record nudge renders unconditionally forever (page.tsx L96–102), unlike the profile nudge which is
gated on
hasCustomerProfile(L103). - The C1 visit-date filter is a native Gregorian
type="date"(search/page.tsx L104–110) in a product where every displayed date is Shamsi; the gender facet re-implements an inlineToggleButtonGroup(L86–100) instead of the sharedGenderToggle; the live-count CTA is the last child of a long scrolling form (L130–140). - C2 ships a fake sort — a
TextField selectwith one hard-codedMenuItem,value="rating", noonChange(results/page.tsx L67–69) — and no filter recap:backToFiltersrenders only inside the EmptyState (L58/L88), so editing filters from a populated list means browser-back. NurseResultCardis information-thin. The result row IS a bookable variant, but the card never names the service — three variants of one nurse render as identical cards differing only in price.nurseGenderandtotalCompletedBookingsare served by the real backend today (REQ-012 delivered them onto the index row — seeservices/search/apis/clientApi.tsNurseSearchResultDto) and never rendered. The variant display name is a genuine wire gap: neitherNurseSearchResult(types.ts) nor the DTO carries it.- C3 doesn't read as a dossier. The «درخواست رزرو» CTA is the last page child (nurse/[nurseId]/page.tsx L92–101)
— below the infinite reviews list when that tab is open — and not sticky.
totalCompletedBookingsis fetched and never shown;TrustBadgeis a static chip with no affordance to learn what was verified; and the profile DTO does not serve gender (clientApi.tsstubsnurseGender: 'female'with an "unused" comment — never render it). - The empty-results copy is nonsense:
search.empty_suggest_city= «شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را امتحان کنید» (fa.json L361) — a family cannot "try Shiraz"; the patient lives where they live.
What already exists (do not rebuild):
- The search architecture: the filter object IS the URL IS the React Query cache key
(
services/search/keys.ts+filterParams.ts),keepPreviousData, debounced Toman price inputs, live count on C1. - The real search backend (
USE_SEARCH_MOCK = falsesince refinement-phase-4): the index row servesnurseName/avatarUrl/distanceKm/nurseGender/totalCompletedBookings;GET nurses/{id}/profileserves bio/years/services/latestReview. - The public trust-badge read:
useNurseTrustBadge(nurseId)→GET nurses/{id}/trust_badge→{ isVerified, approvedAt, credentialTypes[] }(services/verification) — real data for the explainer. - Phase 0's theme/brand/icon registry; phase 1's primitives (EmptyState/ErrorState kit, PageHeader,
<Money>, Jalali date picker, skeleton twins pattern, RatingInput v2 with honest fractional stars); phase 2's shells, safe-area-awareBottomBar, and contextual customer header. Consume them; never fork local variants. - All four data states on C1/C2/C3,
ProfileSkeleton, the honest «کل شهر» district semantics,PriceDisplaymoney rules, the gender facet's humane hint copy.
2. Required reading (do this first)
- audit/customer-storefront.md — the 21 problems + keep-list for this route tree.
- audit/feature-components.md —
NurseResultCard/TrustBadgefindings + the component-layer keep-list (presentational purity, caller-owned i18n, token discipline). - The four pages under
client/src/app/[locale]/(private-routes)/(customer)/listed above, plussearch/useSearchFilters.tsandservices/search/{types.ts,filterParams.ts,apis/clientApi.ts}— know exactly what the wire serves before deciding what needs a REQ. client/src/components/{NurseResultCard,TrustBadge,GenderToggle,ServicePriceRow,PriceDisplay}/— the components this phase owns or extends.client/src/services/verification/types.ts(TrustBadgewire type,publicBadgeState) andhooks/useNurseTrustBadge.ts— the explainer's data source.- ../../../.claude/skills/frontend-designer/SKILL.md — invoke it;
and skim
client/CLAUDE.md"Golden rules". - Product rules: ../../../product/overview/platform-summary.md (the four ground truths), ../../../product/business/04-search-and-matching.md (verified-only, same-gender, variant-is-the-unit), and ../../../product/business/02-nurse-verification.md (what the pipeline actually verifies — the explainer must narrate this truthfully).
3. Scope — build this
3.1 Customer home — a front door that works
- Fix the dead search bar — decision: convert it into an honest search entry point. Replace the free-text
TextFieldwith a tappable search affordance (a faux-inputButtonBasestyled as the search field) that routes to C1 (/search), optionally focusing the category grid. Why not client-side text match: the index has no text column, variant names are not queryable client-side, and the only matchable dataset (5–6 cached category names) is already better served by the category grid directly below — a half-working text field over-promises exactly where trust matters. File the text-search REQ (qover nurse/variant/category names onsearch/nurses) so the bar can upgrade to a real typeahead when the backend serves it; note the upgrade path in the component's JSDoc. - Add the missing
isErrorbranch:usePatients()failure renders the phase-1ErrorStatewith retry — never an eternal spinner. Keep the onboarding redirect logic (isEmpty→ onboarding) exactly as is. - Compact trust strip under the greeting: three icon+label items — «پرداخت امن امانی» · «پرستاران تاییدشده» · «پشتیبانی» — quiet, one row, tokens only. This is ambient reassurance, not a hero; keep it to one line on mobile.
- Gate the patient-record NudgeCard on actual record incompleteness. Derive a completeness signal from the cached
patients data (e.g. a patient missing conditions/age — inspect
services/patientstypes for what's derivable). If no honest signal is derivable client-side, show the nudge only when a patient list exists but a patient was never opened/completed — and make it dismissible (session-scoped) rather than permanent. Never a forever-nudge. - Rebook shortcut row: source recent bookings from the existing
useBookingList('customer')cache (services/bookings) and render up to 2 «رزرو دوباره با …» cards deep-linking to the nurse's C3 profile. Repeat care is the dominant pattern in home nursing. Render nothing (no empty state) when there are no past bookings.
3.2 Search (C1) — Persian-native filters, visible feedback
- Replace the native
type="date"with the phase-1 Jalali controls: a horizontal day-chip strip — «امروز»، «فردا»، then day-name + Shamsi date chips for the next 7 days — plus an entry to the full Jalali picker for later dates. The value stays the same ISO stringdateIntentthe flow already carries (intent-only, never a hard filter — preserve that semantic and its hint copy). - Make the live-count CTA a sticky bottom bar: «مشاهده N پرستار» pinned above the
BottomBar, safe-area aware (compose with phase 2's safe-area handling — don't reimplementenv(safe-area-inset-bottom)locally). The count is the screen's best feedback instrument; it must be visible while adjusting the upper filters. - Reuse the shared
GenderToggleinstead of the divergent inlineToggleButtonGroup.GenderToggleis male/female-only by design (required, never defaulted — booking context); extend it with an opt-inallowAnyprop (adds the «فرقی ندارد» option) rather than forking, keep the booking-context behavior unchanged, and update its co-located test for both modes. - Never render «مشاهده ۰ پرستار» as a tappable CTA. When the live count is 0, the sticky bar shows a non-CTA message («پرستاری با این فیلترها یافت نشد») with the same relaxation hints as 3.6. Full ICU zero-case catalog sweep is (DEFERRED → phase 12) — just don't ship the zero-CTA here.
3.3 Results (C2) — honest header, editable filters
- Tappable filter-recap chip row under the count: category · region (city/district or «کل شهر») · gender · price
range — each chip deep-links back to C1 with state preserved. The filters already live in the URL
(do-not-regress); C1 must initialize from the carried params, not just
category_id— extenduseSearchFiltersto hydrate from a fullsearchParamsToFiltersread. - Kill the fake sort. Replace the single-option
TextField selectwith a static caption «مرتبشده بر اساس امتیاز» (the contract's only MVP sort —SearchSort = 'rating'). Real sort options only if the API grows them (DEFERRED — do not file a REQ for sorts the product hasn't asked for). - Adopt skeleton twins: replace the generic
Skeleton height={112}rows withNurseResultCard.Skeleton(built in 3.4, following phase 1's twin pattern) so loading matches the card anatomy exactly.
3.4 NurseResultCard v2 — the decision card (this phase owns it)
Rebuild the card as the four-second decision unit, staying presentational + memoized with its co-located test:
- Service/variant display name — the row IS a variant; the card must name what is being bought. This field is
missing from the wire (
NurseSearchResultDtohas no display name — verified): file the REQ to denormalizevariantDisplayNameonto the index row. Until it lands, render the row's category name (available via the cached catalog reference data, passed in by the page — the card stays data-agnostic) so multi-variant nurses are at least distinguishable by price row; switch to the served name when the REQ lands. - Nurse gender indicator — load-bearing for same-gender matching;
nurseGenderis served today and never rendered. A quiet chip/glyph («خانم»/«آقا» styled per the design language), never color-only. - Completed-visits count —
totalCompletedBookingsis served today; render «N ویزیت موفق» (fa digits). This carries more information than the uniform verified chip (every row is verified by invariant). - Optional top review tag — one line, e.g. «منظم و دقیق», only if served. The index row carries no review tag (verified): file it as an optional field on the same index-row REQ; the card renders it conditionally.
NurseResultCard.Skeletonstatic twin matching the new anatomy (avatar disc, name+badge row, meta row, price row).- Keep:
memo, stableonSelect, keyboard/focus a11y,PriceDisplayfor money,TrustBadgefor the badge.
3.5 Nurse profile (C3) — the trust dossier + the shared trust components
- Header identity card: large avatar, name, gender chip, years of experience, completed visits («N ویزیت موفق» —
fetched today, never rendered), rating + count. The profile DTO does not serve gender (the client stub is a
placeholder — never render it): file the REQ to add
nurseGendertoNursePublicProfileDto; until it lands, omit the gender chip on C3 (the C2 card and the carriedrequired_genderparam cover the matching flow honestly). VerificationPanel— new shared component (src/components/VerificationPanel/, barrel + test): "what Balinyaar verified" as a check-listed panel, fed byuseNurseTrustBadge(nurseId)— verified state,approvedAt(Shamsi), and one row percredentialTypes[]entry (stable codes → i18n labels, never raw wire values). Render only what is served — no invented steps, no fake dates.- Tappable
TrustBadgeexplainer: giveTrustBadgean opt-inonClick/expand affordance that opens a bottom-sheet (mobile) / dialog (desktop) narrating the verification story — «این پرستار این مراحل را گذرانده است…» — rendering the sameVerificationPanelinside. The default (non-interactive) badge everywhere else is unchanged; updateTrustBadge.test.tsx. Wire it up on C2 cards and C3. File the REQ for public per-step verification detail (step codes + decided dates) so the panel can list identity/Shahkar/license individually with dates; until then the panel shows the credential-type rows + the approval date — honest, served data only. These two are REUSED by phase 8's public-profile preview — build them shared, presentational, caller-fed. - Sticky «درخواست رزرو» bottom CTA (same sticky-bar treatment as 3.2, price-from beside the button) so the CTA survives the infinite reviews list. Keep the carried variant/gender/date param handoff to f7 exactly as is.
- Reviews tab polish: aggregate header uses phase 1's RatingInput v2 (fractional — kill the
Math.roundoverstatement at L260 by consuming the v2 API), review cards get the card-kit treatment. Rating-distribution bars are (DEFERRED → phase 12) unless trivially derivable from served data. - Render the already-fetched
latestReviewsnippet on the services tab if it improves the dossier — optional.
3.6 Empty-results copy — honest relaxation
Replace empty_suggest_city in both catalogs with honest relaxation suggestions the family can actually act on:
widen to the whole city («منطقه را خالی کنید تا کل شهر جستوجو شود» — keep/merge with the existing district line),
try other dates, remove the gender filter. Delete the «مشهد/اصفهان/شیراز» line. Reuse the same relaxation vocabulary
in the C1 zero-count bar (3.2). Final wording sweep is phase 12's; this phase removes the nonsense and lands
plausible copy in both en.json/fa.json.
(DEFERRED): public/guest storefront and landing page (→ phase 13);
save/favorite/share nurses (post-MVP, product decision); collapsing multi-variant nurses into one card (needs the
variant-name REQ served first — note it in the report as a follow-up); C1/C2 Suspense-fallback skeletons beyond what
phase 1's route-level loading.tsx already covers.
4. Mocks & seams in this phase
None introduced. Search and reviews are real (USE_SEARCH_MOCK = false); verification stays mock-primary but the
public trust_badge read flows through the existing services/verification seam either way — this phase only
consumes hooks. Backend gaps become REQ entries appended to
../../shared-working-context/frontend/requests/for-backend.md
— REQ-001…038 are taken; check the tracker for the next free number at execution time (parallel UI phases also
file). Expected filings from this phase:
- Free-text search —
qparam onGET search/nursesmatching nurse/variant/category names (the home bar's upgrade path, 3.1). - Index-row enrichment —
variantDisplayName(required) + optionaltopReviewTagonNurseSearchResultDto(3.4). nurseGenderonNursePublicProfileDto(3.5 header chip).- Public verification-step detail — per-step passed checks + decision dates on (or beside) the
trust_badgepayload (3.5 explainer depth).
The UI stays mock-tolerant: every new render is conditional on the field being served; nothing blocks on a REQ.
5. Critical rules you must not get wrong
- Verified-only invariant. Every returned row is verified by the search-index invariant; the UI never
re-filters or re-checks verification and never fakes a badge state. The explainer adds narration to served
truth —
TrustBadge's three honest states (verified/unverified/expired) and their server-driven derivation stay exactly as they are. - Never render placeholder data as truth. The C3 profile's stubbed
nurseGender: 'female'must not reach the screen; no invented verification steps or dates in the panel. - The filter-object-IS-the-URL-IS-the-query-key architecture stays. Recap chips, C1 hydration, and the sticky CTA
all read/write the same
filterParams(de)serializer; deep-linking and back/forward cache hits must keep working. - Money rules: amounts render only through
PriceDisplay/the money util (BigInt IRR, Toman at the boundary); totals only ever price × sessionCount; never parse money to a float. - Same-gender facet care: never defaulted, hint copy preserved, the chosen value carried into booking as before.
- Design contract: i18n keys in both catalogs; tokens not hexes (
--bal-*/ palette keys only); RTL logical props (marginInlineStart,start/end) — verify at/fafirst; dark mode via tokens; MUI v9 API only; shared components (VerificationPanel, extendedTrustBadge/GenderToggle/NurseResultCard) keep/get co-located tests;clientFetch/cookies/services rules untouched — this phase adds no fetch code outside existing hooks. - Ownership: phase 0 owns
theme//AppIcon/AppButton; phase 1 owns the shared primitives (Jalali picker, RatingInput, state views, skeleton-twin pattern); phase 2 ownslayout/. If a foundation gap blocks you (e.g. a missing icon or sticky-bar primitive), extend the foundation file minimally and note it in your report — never fork a local variant.
6. Definition of Done
On top of the shared definition-of-done.md:
npm run checkgreen;npm run test:cigreen — including updated/new tests forNurseResultCard(+ its Skeleton),TrustBadge(interactive mode),GenderToggle(allowAny), andVerificationPanel.en.json/fa.jsonin sync; «مشهد/اصفهان/شیراز» is gone from both.- The home search affordance routes to C1; a failed patients query shows retry (kill the API to prove it); the record nudge no longer renders for a complete record; a customer with past bookings sees the rebook row.
- C1: Jalali day-chips replace the Gregorian input; the count CTA is sticky and never tappable at count 0; the
gender facet is the shared
GenderToggle. - C2: recap chips deep-link back to C1 with all filters preserved (deep-link a full URL to prove hydration); no interactive-looking dead sort; skeleton twins during load.
- Cards show variant/category label, gender, completed visits; C3 has the sticky CTA, completed visits in the
header,
VerificationPanel, and tappable badges opening the explainer; no gender chip on C3 until the REQ lands. - Visual verification on the four axes —
/fa+/en× light + dark — and mobile + desktop for home, C1, C2, C3 (sticky bars must clear the BottomBar and the home-indicator safe area on mobile). - REQs filed in the tracker with the next free numbers; no edits outside
client/and the tracker/report files.
7. How to test (what a human can verify after this phase)
- Home: log in as the seeded customer → the search bar tap opens C1 (no dead
?q=). Stop the API and reload → an error card with «تلاش مجدد», not an eternal spinner. Restart → home recovers; trust strip visible under the greeting; a customer with a completed booking sees «رزرو دوباره با …». - C1: the date filter shows «امروز/فردا» + Shamsi day chips (no Gregorian browser calendar anywhere). Pick filters → the count CTA stays pinned at the bottom while scrolling; set filters matching nothing → the bar shows the no-results message and is not tappable.
- C2: run a search → recap chips show category/region/gender/price; tap one → C1 opens with every filter pre-filled; browser-back returns to identical results with zero network (cache hit). The header reads «مرتبشده بر اساس امتیاز» as text, not a dropdown.
- Cards: a nurse with multiple variants shows distinguishable cards (service label + price); every card shows gender and «N ویزیت موفق»; tapping the ✓ badge opens the verification bottom-sheet.
- C3: open a profile → header shows completed visits + rating;
VerificationPanellists the served credential types + approval date (Shamsi); open the reviews tab and scroll deep → «درخواست رزرو» stays pinned; a 4.5 average renders as a fractional star row, not five full stars. Tap the CTA → the C4 request form receives the same nurse/variant/gender/date params as before. - Repeat 1–5 on
/en(LTR) and in dark mode; on a mobile viewport confirm both sticky bars sit above the BottomBar.
8. Hand off & document (close the phase)
- Update
client/CLAUDE.md"Project Structure": the C1/C2/C3 line items (Jalali chips, sticky CTA, recap chips, dossier layout) and the newVerificationPanelcomponent entry; noteTrustBadge's new interactive mode andGenderToggle'sallowAnyon their lines. - Write the report at
dev/shared-working-context/reports/ui-phase-4-report.md: what changed per screen, the exact REQ numbers filed (text search, index-row enrichment, profile gender, public step detail), the home-search-bar decision + rationale, the multi-variant-collapse follow-up, and any foundation files you extended. - Save a memory note per operating-rules §8: phase 4 owns
NurseResultCard;VerificationPanel+ theTrustBadgeexplainer are the shared trust components phase 8 consumes; the C3 profile DTO still lacks gender (placeholder field — never render it) until its REQ lands.