frontend phase 4: catalog browse (Home A5) & nurse service builder (B7)
Light up the two faces of the configurable service catalog over a new cached services/catalog domain (consumes the b5 contract; unlocks f6 search). - services/catalog: types/keys/constants/apis(client+mock+seam)/hooks/index + names.ts. Categories & option groups are session-cached reference data (Infinity staleTime, like geography); variant mutations invalidate myVariantsLists() and setQueryData the edited row. Mock-primary (USE_CATALOG_MOCK), one-line swap; mock reproduces the 400 missing-required and (nurse,category,option-set) 409 duplicate rules. - Customer Home (A5): greeting+avatar, search bar (navigates toward f6), data-driven category grid (loading/empty/error), patient nudge from the cached f2 query. Deferred /search placeholder stub. - Nurse Services & prices (B7) at /nurse/services: offerings list (active vs deactivated, edit, soft-deactivate w/ confirm, reactivate, no delete) and a 3-step variant builder (category -> required/optional options -> price+unit+ duration). Required-group gate; Toman->IRR digit-string at the field boundary (no float); live unit-aware estimated total (never from price alone); editable auto display_name; inline 409 duplicate warning; locked category edit form. - Shared, tested components: CategoryTile, PriceDisplay, VariantCard. Money util: tomanToRial + multiplyIrr (integer-safe) + tests. - i18n: catalog/services/search namespaces + home additions + nav.services (both locales, in sync). Icons, routes (SEARCH, NURSE_SERVICES), nurse nav. Gate: npm run check green; npm run test:ci green (147 tests, +18 across 4 suites); npm run build green with NEXT_PUBLIC_API_URL set. Docs: client/CLAUDE.md (Project Structure, caching note, namespaces), STATUS, for-backend REQ-010 (pagination param casing), phase report, mocks registry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,36 @@ for awareness.
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
## frontend-phase-4-b5 — Catalog browse (Home A5) & nurse service builder (B7) — 2026-07-05
|
||||
- **Shipped:** `services/catalog` domain (types/keys/constants/apis[client+mock+seam]/hooks/index) — the b5
|
||||
catalog skeleton + nurse pricing layer. Hooks: `useServiceCategories`, `useCategoryOptionGroups` (both
|
||||
**session-cached reference data**, Infinite `staleTime`), `useMyVariants` (paginated, self-scoped),
|
||||
`useCreateVariant`/`useUpdateVariant`/`useSetVariantActive` (mutations invalidate `catalogKeys.myVariantsLists()`;
|
||||
update `setQueryData`s the row). Shared composites (each tested): **`CategoryTile`** (data-driven Home tile +
|
||||
builder `selected` state), **`PriceDisplay`** (money-util Toman + i18n unit label + unit-aware estimated total),
|
||||
**`VariantCard`** (offering card, active/deactivated distinction, no delete). Money util gained `tomanToRial`
|
||||
(field-boundary Toman→IRR) + `multiplyIrr` (integer-safe estimate). Screens: **customer Home (A5)** — greeting +
|
||||
avatar, search bar (navigates toward f6 `/search`, results deferred), **data-driven category grid**
|
||||
(loading/empty/error), patient nudge (reuses cached f2 `usePatients`, no new fetch); **nurse Services & prices
|
||||
(B7)** at `/nurse/services` (new sidebar tab) — offerings list (active/inactive, edit, soft deactivate w/ confirm,
|
||||
reactivate, empty/skeleton) + **3-step variant builder** (category → required/optional options → price+unit+duration;
|
||||
required-group gate; Toman→IRR digit-string submit; live unit-aware total; editable auto `displayName`; inline
|
||||
`409` duplicate warning; locked-category edit form). Added `catalog`/`services`/`search` i18n namespaces + `home`
|
||||
additions + `nav.services` (both locales); 7 icons (`services`,`category`,`elderly`,`post_surgery`,`infant`,
|
||||
`chronic`,`companionship`); routes `SEARCH`,`NURSE_SERVICES`; deferred `/search` placeholder stub (→ f6).
|
||||
- **Consumes:** dev/contracts/domains/catalog.md (backend-phase-5). Routes `api/v1/catalog/{categories,option_groups}`,
|
||||
`api/v1/nurse_variants/{create,update/{id},set_active/{id},list,get/{id}}`. Wire camelCase; `price_unit` enum;
|
||||
IRR-string money; `409` duplicate listing / `400` missing required dimension.
|
||||
- **Mocked client-side:** `services/catalog` via `catalogMockApi` behind `USE_CATALOG_MOCK` (default `true`) — seeds
|
||||
the 5 real b5 categories + representative option groups (incl. a cross-category one) + the `409`/`400` rules;
|
||||
variant store seeded **empty** so the offerings empty-state demos. Real `catalogClientApi` wired for a one-line
|
||||
flip. See mocks-registry + the report (note: the mock seeds option groups the fresh backend does not — an admin
|
||||
authors them).
|
||||
- **Gate:** npm run check green · npm run test:ci green (147 tests, +18 across 4 suites: catalog components + money)
|
||||
· npm run build green with NEXT_PUBLIC_API_URL set (routes /nurse/services, /search generated; home prerenders).
|
||||
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-010 confirm the list pagination query-param name
|
||||
`pageSize` vs the doc's `page_size`).
|
||||
|
||||
## frontend-phase-3-b4 — Addresses, map picker & nurse coverage areas — 2026-07-03
|
||||
- **Shipped:** three domain services — `services/geography` (cached province→city→district reference lookups;
|
||||
**Infinity `staleTime`** + shared `geographyKeys`; `useProvinces`/`useCities`/`useDistricts`; seam+mock+client),
|
||||
|
||||
@@ -120,3 +120,15 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
this is purely to prefill the client cascade.
|
||||
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
|
||||
- **Status:** open
|
||||
|
||||
## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05
|
||||
- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The
|
||||
`catalog.md` route examples write `?page=&page_size=` (snake_case), but the **working** b4 `serviceAreas`
|
||||
client binds `pageSize` (camelCase, case-insensitive to the server's `PageSize` property) — the f3 report
|
||||
flagged this as the `page_size`→`pageSize` gotcha. The f4 catalog client follows the proven `pageSize` for
|
||||
`catalog/categories` and `nurse_variants/list`; the domain filter `category_id` stays snake_case per the doc.
|
||||
- **Why:** So the real-endpoint swap (f6 flips `USE_CATALOG_MOCK=false`) doesn't silently paginate wrong. If
|
||||
the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match;
|
||||
if it binds `page_size`, tell us and we'll switch the client (one line per list call).
|
||||
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
|
||||
- **Status:** open
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Frontend Phase 4 — Catalog browse (Home A5) & nurse service builder (B7) — Report (2026-07-05)
|
||||
|
||||
Lights up the two faces of the configurable catalog: the **customer Home (A5)** front door and the
|
||||
**nurse "add a service" builder + offerings list (B7 services half)**, over a new cached `services/catalog`
|
||||
domain. Consumes the b5 `catalog` contract. Unlocks search & discovery (f6).
|
||||
|
||||
## What was built
|
||||
|
||||
### `services/catalog` domain (`client/src/services/catalog/`)
|
||||
- **`types.ts`** — DTOs mirrored from [`catalog.md`](../../contracts/domains/catalog.md) (**camelCase** wire):
|
||||
`ServiceCategory`, `ServiceOptionGroup` (`serviceCategoryId: number|null` = cross-category, `isRequired`),
|
||||
`ServiceOptionValue`, `NurseServiceVariant` (`price` = **IRR digit-string**, `priceUnit`, `sessionCount`,
|
||||
`options[]`), `CreateVariantInput`/`UpdateVariantInput`, the `CatalogApi` seam, and helpers
|
||||
`isGroupApplicable` / `optionSetSignature` (the duplicate-listing signature). `PriceUnit` +the `PRICE_UNITS`
|
||||
array are the **only** closed enum — categories/groups/values are data-driven.
|
||||
- **`keys.ts`** — `catalogKeys`: `categories()`, `categoryOptionGroups(id)`, `myVariants(params)` /
|
||||
`myVariantsLists()` (invalidation prefix), `variant(id)`.
|
||||
- **`constants.ts`** — `USE_CATALOG_MOCK` (default `true`); `CATALOG_REFERENCE_STALE_TIME = Infinity` +
|
||||
`CATALOG_REFERENCE_GC_TIME` (reference data cached session-long, like geography); `MY_VARIANTS_STALE_TIME`;
|
||||
page sizes.
|
||||
- **`apis/`** — `clientApi.ts` (real, action-style routes, camelCase bodies, `pageSize` pagination),
|
||||
`mockApi.ts` + `seed.ts` (in-memory, behind the seam), `index.ts` (config-selected seam).
|
||||
- **`hooks/`** (one per file) — `useServiceCategories`, `useCategoryOptionGroups` (cached reference data);
|
||||
`useMyVariants` (paginated, auth-gated, self-scoped); `useCreateVariant`, `useUpdateVariant`
|
||||
(`setQueryData` the row + invalidate), `useSetVariantActive` (deactivate/reactivate). `index.ts` barrels hooks.
|
||||
- **`names.ts`** — `pickCatalogName(row, locale)` locale-label helper (fa primary).
|
||||
|
||||
### Shared components (`client/src/components/`, each with a co-located `*.test.tsx`)
|
||||
- **`CategoryTile`** — data-driven, tappable category tile (icon disc + localised label; `selected` state for
|
||||
the builder; robust `iconKey`→icon fallback to a generic `category` icon).
|
||||
- **`PriceDisplay`** — renders `{Toman} {unit}` via the f0 money util + an i18n unit label off `price_unit`, and
|
||||
the **unit-aware estimated total** (`price × sessionCount`, integer-safe) only when a duration is present.
|
||||
- **`VariantCard`** — a nurse offering: `display_name`, `PriceDisplay`, active/deactivated distinction (dimmed +
|
||||
neutral chip + "can't be booked" hint), Edit + Deactivate/Reactivate actions — **no delete affordance**.
|
||||
|
||||
### Money util (`client/src/utils/money.ts`)
|
||||
- `tomanToRial(value)` — the sanctioned **Toman→IRR digit-string** conversion at the field boundary.
|
||||
- `multiplyIrr(value, count)` — integer-safe `price × count` for the estimated total (never from price alone).
|
||||
- Both unit-tested (`money.test.ts`).
|
||||
|
||||
### Screens
|
||||
- **Customer Home (A5)** — `app/[locale]/(private-routes)/(customer)/page.tsx` (rewritten): greeting + avatar
|
||||
(`useMe` firstName / initial), **search bar** (navigates toward `/search?q=…`; execution deferred to f6),
|
||||
**data-driven category grid** (`useServiceCategories`; loading skeletons / empty / error+retry; tiles carry
|
||||
`service_category_id` → `/search?category_id=`), the complete-patient-record nudge (reuses the **cached f2
|
||||
`usePatients`** — no new fetch), and the preserved first-login onboarding gate.
|
||||
- **`/search`** — deferred `PlaceholderScreen` stub so the Home CTAs don't dead-end (echoes the q/category).
|
||||
- **Nurse Services & prices (B7)** — `app/[locale]/(private-routes)/nurse/services/` (new sidebar tab). `page.tsx`
|
||||
switches between `MyServicesList` (offerings — active/inactive, edit, soft-deactivate w/ confirm, reactivate,
|
||||
empty/skeleton) and `VariantBuilder` (create/edit).
|
||||
|
||||
### Builder (`VariantBuilder.tsx`)
|
||||
- **Create** = 3-step stepper (reuses the f0 `StepperHeader`): category (CategoryTile grid) → options
|
||||
(single-select `ToggleButtonGroup` per group; required badge; **blocks advancing until every required group is
|
||||
answered**, cross-category groups included) → price+unit+duration. Price entered in **Toman** → submitted as an
|
||||
**IRR digit-string** (`tomanToRial`, no float on the path); **live unit-aware estimated total** via
|
||||
`PriceDisplay`; editable auto-generated `display_name`. The duplicate-listing **`409`** shows a friendly inline
|
||||
warning. **Edit** locks the category + option-set and edits only price/unit/duration/display.
|
||||
|
||||
### Wiring
|
||||
- i18n: `catalog` / `services` / `search` namespaces + `home` additions + `nav.services` (both locales, in sync,
|
||||
313 keys). Icons: `services`, `category`, `elderly`, `post_surgery`, `infant`, `chronic`, `companionship`.
|
||||
Routes: `SEARCH`, `NURSE_SERVICES`. Nurse sidebar gains **Services**.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
|
||||
Run `npm run dev` (mock is on by default — no backend needed). Follow the phase §7 steps:
|
||||
1. **Home** — sign in as a customer → greeting + avatar, search bar, and the **category grid** (5 seeded
|
||||
categories ordered by `sortOrder`); the patient nudge shows/hides off the cached patient state (React Query
|
||||
Devtools: the `patients` query is **reused**, not refetched).
|
||||
2. **Locale + RTL** — toggle `fa`↔`en`: labels translate, `dir` flips, tiles/grid mirror; Persian unit labels
|
||||
(ساعتی/روزانه/شبانهروزی) read correctly.
|
||||
3. **Build a variant** — sign in as a nurse → `/nurse/services` → `+ افزودن خدمت` → pick **مراقبت از سالمند**;
|
||||
in options, try **Next** without answering **نوع شیفت** (required) → blocked, the required badge turns red;
|
||||
answer it → Next; enter a **Toman** price + unit (ساعتی) + a duration → the **estimated total** updates from
|
||||
`price × duration`; edit the auto `display_name`; submit → the card appears (`… تومان ساعتی`).
|
||||
4. **Duplicate** — create a second variant with the **same category + same option-set** → the friendly
|
||||
`409` duplicate warning shows inline; no crash / no generic toast.
|
||||
5. **Edit + deactivate** — edit a variant's price/name → the list reflects it without a full refetch (Devtools:
|
||||
`setQueryData` + single invalidation). Deactivate → confirm dialog → the row dims to the deactivated state
|
||||
with the "can't be booked" hint; **no delete** option. Reactivate from the inactive row.
|
||||
6. **Caching** — Devtools: `catalogKeys.categories()` / `categoryOptionGroups` are served from cache across Home
|
||||
and every builder step (no per-step refetch); a variant mutation invalidates only `myVariants`.
|
||||
7. **Gate** — `npm run check` + `npm run test:ci` pass.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- **`CatalogApi`** — `client/src/services/catalog/apis/mockApi.ts` (+ `seed.ts`), behind `USE_CATALOG_MOCK`
|
||||
(default `true`). Faithfully reproduces the b5 create validation (`400` missing-required / bad-price) and the
|
||||
`(nurse, category, option-set)` duplicate **`409`**; categories mirror the real seed; the variant store starts
|
||||
**empty** (so the empty state demos). See [`mocks-registry.md`](./mocks-registry.md) (`CatalogApi` row).
|
||||
**Swap = one line** (`USE_CATALOG_MOCK = false`); `catalogClientApi` is already wired to the action-style routes.
|
||||
Caveat recorded there: the mock seeds representative **option groups the fresh backend does not** (an admin
|
||||
authors them per category) — after the swap, categories have no groups until seeded server-side.
|
||||
|
||||
## Contracts
|
||||
- **Consumed:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (backend-phase-b5) —
|
||||
types/services derive from it; no shape guessed.
|
||||
- **Requested:** `REQ-010` in [`for-backend.md`](../frontend/requests/for-backend.md) — confirm/align the list
|
||||
pagination query-param name (`pageSize`, per the proven b4 binding, vs the doc's `page_size`).
|
||||
- **Produced:** none (frontend produces no contract).
|
||||
|
||||
## Docs updated
|
||||
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — *Project Structure* (customer `search` stub, nurse `services`
|
||||
route, `services/catalog` domain, `CategoryTile`/`PriceDisplay`/`VariantCard`); the reference-data caching note
|
||||
(catalog = second long-lived cached domain); the i18n namespaces list (`catalog`/`services`/`search`, `home`).
|
||||
- This report + `STATUS.md` + `mocks-registry.md` + `for-backend.md` (REQ-010).
|
||||
- No product-doc rule change was needed; the estimated-total presentation rule (total only from
|
||||
price × session_count, never price alone) is captured here and enforced in `PriceDisplay`/the builder.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **f6 (search & discovery)** — the Home **search bar** hands a `q` / `service_category_id` to `/search` (today a
|
||||
placeholder); f6 builds the results, filters, and nurse cards, and can **reuse** `CategoryTile`, `PriceDisplay`,
|
||||
the cached `useServiceCategories`/`useCategoryOptionGroups`, and the `catalog`/`search` namespaces. The **variant
|
||||
builder is what populates the index f6 reads** (a nurse must have ≥1 active variant + coverage to appear).
|
||||
- **Backend** — deliver `REQ-010` (pagination param confirmation), then f6 can flip `USE_CATALOG_MOCK=false`.
|
||||
- **Deferred (unchanged):** admin catalog manager (→ f15), nurse availability slots, public nurse-profile service
|
||||
rows (→ f6 C3), holiday/surge pricing.
|
||||
Reference in New Issue
Block a user