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:
hamid
2026-07-05 17:33:44 +03:30
parent 5839b3508f
commit 99ebf5d881
41 changed files with 2382 additions and 26 deletions
+15 -3
View File
@@ -118,7 +118,8 @@ client/
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout
│ │ │ ├── page.tsx # / (A5 home — 'use client'; first-login onboarding gate + record/profile nudges)
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
│ │ │ ├── search/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/page.tsx # /bookings
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
@@ -129,6 +130,7 @@ client/
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder co-located)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch)
│ │ │ ├── verification/page.tsx # /nurse/verification
@@ -153,6 +155,9 @@ client/
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
│ ├── PatientCard/ # E1 patient summary card + edit/archive actions
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
@@ -201,6 +206,7 @@ client/
│ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -284,13 +290,16 @@ async function MyServerComponent() {
- `'shell'` — actor-shell titles + the not-yet-built placeholder body
- `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive)
- `'onboarding'` — the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels)
- `'home'` — the A5 family home (greeting + record/profile nudges)
- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
- `'profile'` — the customer profile + emergency contact
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints)
- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link
- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
@@ -532,7 +541,10 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api`
level is fetched **once** and served from cache across every consumer (the address form, the coverage editor,
and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage
areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future
reference data; do not reinvent per-consumer fetching.
reference data; do not reinvent per-consumer fetching. **`services/catalog` (f4) is the second long-lived
cached reference domain:** admin-seeded categories + a category's option groups/values use the same Infinite
`staleTime`/`gcTime` (`CATALOG_REFERENCE_*`) so the Home grid and every builder step read them from cache;
the nurse's own **variant list** is the mutable side — mutations invalidate `catalogKeys.myVariantsLists()`.
- **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
+82 -2
View File
@@ -7,6 +7,7 @@
"profile": "Profile",
"bank": "Bank account",
"coverage": "Coverage",
"services": "Services",
"dashboard": "Dashboard",
"verification": "Verification",
"visits": "Visits",
@@ -43,8 +44,14 @@
"placeholder_body": "This area will be built in a later phase."
},
"home": {
"greeting": "Welcome to Balinyaar",
"greeting_named": "Hi, {name}",
"greeting_plain": "Hi there",
"subtitle": "Manage care for the people you love.",
"search_placeholder": "Search a service or nurse…",
"search_action": "Search",
"categories_title": "Services",
"categories_error": "Couldn't load services.",
"categories_empty": "No services have been set up yet.",
"nudge_patient_title": "Complete the patient record",
"nudge_patient_body": "Add conditions, medications and routine so nurses arrive prepared.",
"nudge_patient_cta": "Open patients",
@@ -134,7 +141,7 @@
"unverified_title": "Your profile isn't active yet",
"unverified_body": "Complete identity verification to appear to families and receive bookings.",
"unverified_cta": "Complete verification",
"deferred_services": "You'll add your services & prices and available days in a later step."
"deferred_services": "Manage your services & prices in the Services section; available days come in a later step."
},
"bank": {
"title": "Bank account",
@@ -235,6 +242,79 @@
"remove_confirm": "Remove",
"removed": "Coverage area removed"
},
"catalog": {
"unit_per_hour": "hourly",
"unit_per_session": "per session",
"unit_per_half_day": "per half-day",
"unit_per_day": "daily",
"unit_per_24h": "24-hour (live-in)",
"count_per_hour": "hours",
"count_per_session": "sessions",
"count_per_half_day": "half-days",
"count_per_day": "days",
"count_per_24h": "days (24h)",
"estimate_label": "Estimated total",
"estimate_for": "for {count} {unit}"
},
"services": {
"title": "Services & prices",
"subtitle": "The services you offer and what each costs.",
"add": "Add service",
"empty_title": "No services yet",
"empty_body": "Add at least one active, priced service so families can find and book you.",
"active_chip": "Active",
"inactive_chip": "Deactivated",
"inactive_hint": "This service is deactivated and can't be booked.",
"edit": "Edit",
"deactivate": "Deactivate",
"activate": "Reactivate",
"deactivate_title": "Deactivate this service?",
"deactivate_body": "It becomes unbookable and drops out of search. You can reactivate it anytime — it is never deleted.",
"deactivate_confirm": "Deactivate",
"deactivated_toast": "Service deactivated",
"activated_toast": "Service reactivated",
"toggle_error": "That couldn't be done. Please try again.",
"builder_add_title": "Add a new service",
"builder_edit_title": "Edit service",
"step_category": "Category",
"step_options": "Options",
"step_price": "Price",
"category_title": "Choose the service category",
"category_subtitle": "Every service belongs to one category.",
"categories_error": "Couldn't load categories. Please try again.",
"category_locked": "The category can't be changed after a service is created.",
"options_title": "Set the service options",
"options_subtitle": "Pick one option for each required item.",
"options_none": "This category has no options to configure.",
"required_badge": "Required",
"optional_badge": "Optional",
"options_incomplete": "Answer every required item to continue.",
"price_title": "Set the price and unit",
"price_label": "Price (Toman)",
"price_hint": "Enter the price in Toman.",
"price_required": "Enter a valid price greater than zero.",
"unit_label": "Price unit",
"duration_label": "Duration / count",
"duration_hint": "Optional — add a duration to preview the estimated total.",
"rate_note": "This is the base rate per unit, not the full total.",
"display_name_label": "Display name",
"display_name_hint": "Auto-generated from your options — you can edit it.",
"duplicate_warning": "You already have a service with these exact details. Change an option or the category.",
"summary_options": "Options",
"summary_none": "No options",
"next": "Next",
"submit_create": "Add service",
"submit_save": "Save changes",
"create_error": "The service couldn't be saved. Check your inputs and try again.",
"created_toast": "Service added",
"saved_toast": "Changes saved"
},
"search": {
"title": "Search",
"deferred": "Search and results arrive in the next phase.",
"query_echo": "You searched for “{query}”.",
"category_echo": "Filtered to the selected service category."
},
"auth": {
"customer_title": "Sign in to Balinyaar",
"customer_subtitle": "Sign in with your mobile number",
+82 -2
View File
@@ -7,6 +7,7 @@
"profile": "پروفایل",
"bank": "حساب بانکی",
"coverage": "پوشش",
"services": "خدمات",
"dashboard": "داشبورد",
"verification": "احراز هویت",
"visits": "ویزیت‌ها",
@@ -43,8 +44,14 @@
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود."
},
"home": {
"greeting": "به بلینیار خوش آمدید",
"greeting_named": "سلام، {name}",
"greeting_plain": "سلام",
"subtitle": "مراقبت از عزیزانتان را مدیریت کنید.",
"search_placeholder": "جستجوی خدمت یا پرستار…",
"search_action": "جستجو",
"categories_title": "خدمات",
"categories_error": "خدمات بارگذاری نشد.",
"categories_empty": "هنوز خدمتی تعریف نشده است.",
"nudge_patient_title": "تکمیل پروندهٔ بیمار",
"nudge_patient_body": "وضعیت‌ها، داروها و روتین را اضافه کنید تا پرستار آماده حاضر شود.",
"nudge_patient_cta": "مشاهدهٔ بیماران",
@@ -134,7 +141,7 @@
"unverified_title": "پروفایل شما هنوز فعال نیست",
"unverified_body": "برای نمایش به خانواده‌ها و دریافت رزرو، احراز هویت را تکمیل کنید.",
"unverified_cta": "تکمیل احراز هویت",
"deferred_services": "خدمات و قیمت‌ها و روزهای کاری را در مرحله بعد اضافه می‌کنید."
"deferred_services": "خدمات و قیمت‌های خود را در بخش «خدمات و قیمت‌ها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه می‌شود."
},
"bank": {
"title": "حساب بانکی",
@@ -235,6 +242,79 @@
"remove_confirm": "حذف",
"removed": "منطقهٔ تحت پوشش حذف شد"
},
"catalog": {
"unit_per_hour": "ساعتی",
"unit_per_session": "هر جلسه",
"unit_per_half_day": "نیم‌روزی",
"unit_per_day": "روزانه",
"unit_per_24h": "شبانه‌روزی",
"count_per_hour": "ساعت",
"count_per_session": "جلسه",
"count_per_half_day": "نیم‌روز",
"count_per_day": "روز",
"count_per_24h": "شبانه‌روز",
"estimate_label": "برآورد کل",
"estimate_for": "برای {count} {unit}"
},
"services": {
"title": "خدمات و قیمت‌ها",
"subtitle": "خدماتی که ارائه می‌دهید و قیمت هرکدام.",
"add": "افزودن خدمت",
"empty_title": "هنوز خدمتی اضافه نکرده‌اید",
"empty_body": "برای دیده‌شدن و دریافت رزرو، حداقل یک خدمت فعال و دارای قیمت اضافه کنید.",
"active_chip": "فعال",
"inactive_chip": "غیرفعال",
"inactive_hint": "این خدمت غیرفعال است و قابل رزرو نیست.",
"edit": "ویرایش",
"deactivate": "غیرفعال‌سازی",
"activate": "فعال‌سازی دوباره",
"deactivate_title": "این خدمت غیرفعال شود؟",
"deactivate_body": "این خدمت قابل رزرو نخواهد بود و از نتایج جستجو حذف می‌شود. هر زمان می‌توانید دوباره فعالش کنید — هرگز حذف نمی‌شود.",
"deactivate_confirm": "غیرفعال کن",
"deactivated_toast": "خدمت غیرفعال شد",
"activated_toast": "خدمت دوباره فعال شد",
"toggle_error": "این عملیات انجام نشد. دوباره تلاش کنید.",
"builder_add_title": "افزودن خدمت جدید",
"builder_edit_title": "ویرایش خدمت",
"step_category": "دسته",
"step_options": "گزینه‌ها",
"step_price": "قیمت",
"category_title": "دستهٔ خدمت را انتخاب کنید",
"category_subtitle": "هر خدمت به یک دسته تعلق دارد.",
"categories_error": "دسته‌ها بارگذاری نشد. دوباره تلاش کنید.",
"category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.",
"options_title": "گزینه‌های خدمت را مشخص کنید",
"options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.",
"options_none": "این دسته گزینه‌ای برای تنظیم ندارد.",
"required_badge": "الزامی",
"optional_badge": "اختیاری",
"options_incomplete": "برای ادامه، همهٔ موارد الزامی را انتخاب کنید.",
"price_title": "قیمت و واحد را تعیین کنید",
"price_label": "قیمت (تومان)",
"price_hint": "قیمت را به تومان وارد کنید.",
"price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.",
"unit_label": "واحد قیمت",
"duration_label": "مدت / تعداد",
"duration_hint": "اختیاری — برای نمایش برآورد هزینهٔ کل، مدت را وارد کنید.",
"rate_note": "این نرخ پایه به ازای هر واحد است، نه هزینهٔ کل.",
"display_name_label": "نام نمایشی",
"display_name_hint": "به‌صورت خودکار از گزینه‌ها ساخته می‌شود؛ می‌توانید تغییرش دهید.",
"duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.",
"summary_options": "گزینه‌ها",
"summary_none": "بدون گزینه",
"next": "بعدی",
"submit_create": "ثبت خدمت",
"submit_save": "ذخیره تغییرات",
"create_error": "ثبت خدمت انجام نشد. ورودی‌ها را بررسی کرده و دوباره تلاش کنید.",
"created_toast": "خدمت اضافه شد",
"saved_toast": "تغییرات ذخیره شد"
},
"search": {
"title": "جستجو",
"deferred": "جستجو و نتایج در فاز بعدی اضافه می‌شود.",
"query_echo": "جستجوی شما: «{query}».",
"category_echo": "محدود به دستهٔ خدمت انتخاب‌شده."
},
"auth": {
"customer_title": "ورود به بلینیار",
"customer_subtitle": "با شماره موبایل خود وارد شوید",
@@ -1,12 +1,14 @@
'use client';
import { FunctionComponent, useEffect } from 'react';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
interface NudgeCardProps {
icon: string;
@@ -39,10 +41,13 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
);
/**
* A5 — the family Home. First-login gate: a customer with no patients is sent into onboarding
* (A3). Once a patient exists it shows the "complete patient record" nudge (and a profile
* nudge until the profile is complete). The redirect waits for a settled list so a post-create
* refetch never bounces the user back to onboarding.
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
*
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
* a settled list so a post-create refetch never bounces the user back to onboarding.
*/
export default function CustomerHomePage() {
const t = useTranslations('home');
@@ -52,9 +57,6 @@ export default function CustomerHomePage() {
const { data: me } = useMe();
const { data } = usePatients();
// A customer with no patients is a first-login user → onboarding. `useCreatePatient` primes
// the list cache on success, so a just-onboarded user never transiently reads total===0 here
// (no bounce back); a genuinely empty list always renders loading, never a flash of Home.
const isEmpty = data?.total === 0;
useEffect(() => {
@@ -67,17 +69,29 @@ export default function CustomerHomePage() {
const href = (path: string) => `/${locale}${path}`;
const profileComplete = me?.hasCustomerProfile ?? false;
const firstName = me?.firstName?.trim() || null;
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('greeting')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
</Avatar>
<Box>
<Typography variant="h5" component="h1">
{greeting}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
</Stack>
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard
icon="patients"
@@ -98,3 +112,99 @@ export default function CustomerHomePage() {
</Box>
);
}
/**
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
* → frontend-phase-6-b7.
*/
const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
};
return (
<Box component="form" onSubmit={submit} role="search">
<TextField
fullWidth
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_action')}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</InputAdornment>
),
},
}}
/>
</Box>
);
};
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
const t = useTranslations('home');
const tc = useTranslations('common');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useServiceCategories();
const categories = data?.items ?? [];
return (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('categories_title')}
</Typography>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
))}
</Box>
) : isError ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
</Paper>
) : categories.length === 0 ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_empty')}
</Typography>
</Paper>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</Stack>
);
};
@@ -0,0 +1,35 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components';
/**
* Search landing — **DEFERRED → frontend-phase-6-b7**. The A5 Home search bar and category tiles
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams`
* needs a Suspense boundary under static rendering.
*/
export default function SearchPage() {
return (
<Suspense fallback={<AppLoading />}>
<SearchDeferred />
</Suspense>
);
}
function SearchDeferred() {
const t = useTranslations('search');
const params = useSearchParams();
const query = params.get('q');
const categoryId = params.get('category_id');
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined;
return (
<PlaceholderScreen
icon="search"
title={t('title')}
description={[t('deferred'), echo].filter(Boolean).join(' ')}
/>
);
}
@@ -0,0 +1,154 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, VariantCard } from '@/components';
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
import type { NurseServiceVariant } from '@/services/catalog/types';
interface MyServicesListProps {
onAdd: () => void;
onEdit: (variant: NurseServiceVariant) => void;
}
/**
* The nurse's offerings list (`ListMyVariants`) — active + inactive variants as `VariantCard`s, with
* loading skeletons and a prominent empty state. Deactivate opens a confirm dialog (soft only — the
* variant becomes unbookable and drops out of search, never deleted); reactivating an inactive row is
* non-destructive, so it fires directly. Mutations invalidate `myVariants` via the hook.
*/
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
const t = useTranslations('services');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useMyVariants();
const setActive = useSetVariantActive();
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
const variants = data?.items ?? [];
const isEmpty = !isLoading && variants.length === 0;
const toggleActive = (variant: NurseServiceVariant) => {
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
if (variant.isActive) {
setDeactivateTarget(variant);
return;
}
setActive.mutate(
{ id: variant.id, isActive: true },
{
onSuccess: () => enqueueSnackbar(t('activated_toast'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('toggle_error'), { variant: 'error' }),
},
);
};
const confirmDeactivate = () => {
if (!deactivateTarget) return;
const id = deactivateTarget.id;
setDeactivateTarget(null);
setActive.mutate(
{ id, isActive: false },
{
onSuccess: () => enqueueSnackbar(t('deactivated_toast'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('toggle_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{!isEmpty && !isLoading ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ m: 0, flexShrink: 0 }}>
{t('add')}
</AppButton>
) : null}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
))}
</Stack>
) : isEmpty ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="services" size={40} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 420 }}>
{t('empty_body')}
</Typography>
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
</Paper>
) : (
<Stack sx={{ gap: 1.5 }}>
{variants.map((variant) => (
<VariantCard
key={variant.id}
variant={variant}
onEdit={() => onEdit(variant)}
onToggleActive={() => toggleActive(variant)}
/>
))}
</Stack>
)}
<Dialog open={Boolean(deactivateTarget)} onClose={() => setDeactivateTarget(null)}>
<DialogTitle>{t('deactivate_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('deactivate_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setDeactivateTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmDeactivate}>
{t('deactivate_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
};
export default MyServicesList;
@@ -0,0 +1,490 @@
'use client';
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, PriceDisplay, StepperHeader } from '@/components';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
useCategoryOptionGroups,
useCreateVariant,
useServiceCategories,
useUpdateVariant,
} from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import {
PRICE_UNITS,
type NurseServiceVariant,
type PriceUnit,
type VariantOptionSelection,
} from '@/services/catalog/types';
interface VariantBuilderProps {
/** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
}
const DEFAULT_UNIT: PriceUnit = 'per_hour';
const MAX_PRICE_DIGITS = 12;
const MAX_DURATION_DIGITS = 4;
/**
* The nurse variant builder (`CreateVariant` / `UpdateVariant`).
*
* **Create** is a 3-step stepper: pick category → answer required/optional option groups → price +
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered
* in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe,
* never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone;
* `display_name` auto-generates from the chosen labels and is editable (left blank ⇒ the server
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning.
*
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
*/
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel }) => {
const t = useTranslations('services');
const tCatalog = useTranslations('catalog');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const isEdit = initial !== null;
const createVariant = useCreateVariant();
const updateVariant = useUpdateVariant();
const submitting = createVariant.isPending || updateVariant.isPending;
// --- Create-only state (category → options) ---
const [activeStep, setActiveStep] = useState(0);
const [categoryId, setCategoryId] = useState<number | null>(initial?.serviceCategoryId ?? null);
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
const [optionsError, setOptionsError] = useState(false);
// --- Shared price state (both create step 3 and edit) ---
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : '');
const [priceUnit, setPriceUnit] = useState<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
const [priceError, setPriceError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const categoriesQuery = useServiceCategories();
const categories = categoriesQuery.data?.items ?? [];
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId);
// Stable reference so the auto-name useMemo below isn't invalidated on every render by a fresh `[]`.
const groups = useMemo(() => optionGroupsQuery.data ?? [], [optionGroupsQuery.data]);
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// Auto-generated display name preview (create): category + chosen value labels, in the active locale.
const autoName = useMemo(() => {
if (isEdit) return initial.displayName;
if (!selectedCategory) return '';
const valueLabels = groups
.map((group) => {
const valueId = selectedOptions[group.id];
const value = valueId == null ? null : group.values.find((candidate) => candidate.id === valueId);
return value ? pickCatalogName(value, locale) : null;
})
.filter((label): label is string => label !== null);
return [pickCatalogName(selectedCategory, locale), ...valueLabels].join(' · ');
}, [isEdit, initial, selectedCategory, groups, selectedOptions, locale]);
const displayValue = displayNameOverride ?? autoName;
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
const irr = priceValid ? tomanToRial(priceToman) : null;
const durationInt = durationStr ? Number(durationStr) : 0;
const sessionCount = durationInt > 0 ? durationInt : null;
const selectCategory = (id: number) => {
if (id === categoryId) return;
// Switching category invalidates the previous category's option answers + auto-name.
setCategoryId(id);
setSelectedOptions({});
setDisplayNameOverride(null);
setOptionsError(false);
};
const changeOption = (groupId: number, valueId: number | null) => {
setOptionsError(false);
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
setSelectedOptions((prev) => {
const next = { ...prev };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
return next;
});
};
const goNextFromOptions = () => {
if (missingRequiredGroups.length > 0) {
setOptionsError(true);
return;
}
setActiveStep(2);
};
const validatePrice = () => {
if (!priceValid) {
setPriceError(true);
return false;
}
return true;
};
const submit = () => {
if (!validatePrice() || irr == null) return;
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined;
if (isEdit) {
updateVariant.mutate(
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } },
{
onSuccess: () => {
enqueueSnackbar(t('saved_toast'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('create_error'), { variant: 'error' }),
},
);
return;
}
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({
optionGroupId: Number(groupId),
optionValueId: valueId,
}));
createVariant.mutate(
{ serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName },
{
onSuccess: () => {
enqueueSnackbar(t('created_toast'), { variant: 'success' });
onDone();
},
onError: (error) => {
// The duplicate-listing conflict is a friendly inline warning, never a generic toast.
if (error instanceof ApiError && error.status === 409) setDuplicate(true);
else enqueueSnackbar(t('create_error'), { variant: 'error' });
},
},
);
};
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('price_label')}
value={priceToman}
onChange={(event) => {
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS));
if (priceError) setPriceError(false);
if (duplicate) setDuplicate(false);
}}
error={priceError}
helperText={priceError ? t('price_required') : t('price_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{irr ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
{t('rate_note')}
</Typography>
) : null}
</Paper>
) : null}
<TextField
label={t('display_name_label')}
value={displayValue}
onChange={(event) => setDisplayNameOverride(event.target.value)}
helperText={t('display_name_hint')}
fullWidth
/>
{duplicate ? (
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 600 }}>
{t('duplicate_warning')}
</Typography>
</Paper>
) : null}
</Stack>
);
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
{initial.options.length > 0 ? (
initial.options.map((option) => (
<Chip
key={option.optionGroupId}
size="small"
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
/>
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</Paper>
{priceStep}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
{tc('cancel')}
</AppButton>
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
{submitting ? tc('saving') : t('submit_save')}
</AppButton>
</Stack>
</Box>
);
}
// --- Create mode: the 3-step stepper ---
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_add_title')}
</Typography>
<StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]}
activeStep={activeStep}
/>
{activeStep === 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
))}
</Box>
) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
</Stack>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</Stack>
) : null}
{activeStep === 1 ? (
<Stack sx={{ gap: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
) : (
groups.map((group) => {
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: isMissing
? 'var(--bal-error)'
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
fontWeight: 600,
}}
/>
</Stack>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={selectedOptions[group.id] ?? null}
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
sx={{ flexWrap: 'wrap' }}
>
{group.values.map((value) => (
<ToggleButton key={value.id} value={value.id}>
{pickCatalogName(value, locale)}
</ToggleButton>
))}
</ToggleButtonGroup>
</Stack>
);
})
)}
{optionsError && missingRequiredGroups.length > 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 600 }}>
{t('options_incomplete')}
</Typography>
) : null}
</Stack>
) : null}
{activeStep === 2 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('price_title')}
</Typography>
</Box>
{priceStep}
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
<AppButton
variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
disabled={submitting}
sx={{ m: 0 }}
>
{activeStep === 0 ? tc('cancel') : tc('back')}
</AppButton>
{activeStep === 0 ? (
<AppButton
color="primary"
variant="contained"
onClick={() => setActiveStep(1)}
disabled={categoryId == null}
sx={{ m: 0 }}
>
{t('next')}
</AppButton>
) : activeStep === 1 ? (
<AppButton color="primary" variant="contained" onClick={goNextFromOptions} sx={{ m: 0 }}>
{t('next')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
{submitting ? tc('saving') : t('submit_create')}
</AppButton>
)}
</Stack>
</Box>
);
};
export default VariantBuilder;
@@ -0,0 +1,35 @@
'use client';
import { useState } from 'react';
import type { NurseServiceVariant } from '@/services/catalog/types';
import MyServicesList from './MyServicesList';
import VariantBuilder from './VariantBuilder';
type BuilderState = { open: false } | { open: true; editing: NurseServiceVariant | null };
/**
* Nurse **Services & prices** (the services half of B7). Switches in-page between the offerings list
* and the variant builder (create/edit) — a stepper is roomier than a dialog, and colocating the
* mode keeps the flow (list → build → back to list) simple with no extra routes. The `key` remounts
* the builder so create/edit/another-variant each start from clean state.
*/
export default function NurseServicesPage() {
const [builder, setBuilder] = useState<BuilderState>({ open: false });
if (builder.open) {
return (
<VariantBuilder
key={builder.editing?.id ?? 'new'}
initial={builder.editing}
onDone={() => setBuilder({ open: false })}
onCancel={() => setBuilder({ open: false })}
/>
);
}
return (
<MyServicesList
onAdd={() => setBuilder({ open: true, editing: null })}
onEdit={(variant) => setBuilder({ open: true, editing: variant })}
/>
);
}
@@ -0,0 +1,39 @@
import { FunctionComponent } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import CategoryTile, { CategoryTileProps } from './CategoryTile';
const ComponentToTest: FunctionComponent<CategoryTileProps> = (props) => (
<ThemeProvider>
<CategoryTile {...props} />
</ThemeProvider>
);
describe('<CategoryTile/> component', () => {
it('renders the localised label', () => {
render(<ComponentToTest label="Elderly Care" />);
expect(screen.getByText('Elderly Care')).toBeInTheDocument();
});
it('renders a known category icon for a mapped iconKey', () => {
const { container } = render(<ComponentToTest label="Elderly Care" iconKey="elderly" />);
expect(container.querySelector('[data-icon="elderly"]')).toBeInTheDocument();
});
it('falls back to the generic category icon for an unknown or missing iconKey', () => {
const { container } = render(<ComponentToTest label="Something" iconKey="unmapped_key" />);
expect(container.querySelector('[data-icon="category"]')).toBeInTheDocument();
});
it('calls onClick when tapped', () => {
const onClick = jest.fn();
render(<ComponentToTest label="Infant Care" iconKey="infant" onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('exposes its selected state for the builder category step', () => {
render(<ComponentToTest label="Elderly Care" iconKey="elderly" selected />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -0,0 +1,80 @@
import { FunctionComponent } from 'react';
import { Box, ButtonBase, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
/**
* Service-category `iconKey`s we render with a dedicated icon. Any other key (or a missing one from
* real data whose icons we don't control) falls back to the generic `category` icon, so the tile is
* robust regardless of what the backend seeds — never an AppIcon "not found" warning.
*/
const KNOWN_CATEGORY_ICONS = new Set(['elderly', 'post_surgery', 'infant', 'chronic', 'companionship']);
function resolveIcon(iconKey?: string | null): string {
return iconKey && KNOWN_CATEGORY_ICONS.has(iconKey) ? iconKey : 'category';
}
export interface CategoryTileProps {
/** Category name, already localised by the caller (`nameFa`/`nameEn` picked by locale). */
label: string;
/** Optional backend icon hint; unknown/missing keys fall back to a generic category icon. */
iconKey?: string | null;
/** Tapping the tile carries the category into the (future f6) search flow, or selects it in the builder. */
onClick?: () => void;
/** Selected state (the nurse builder's category step); the Home grid leaves it unset. */
selected?: boolean;
}
/**
* A tappable tile for one service category — the customer Home grid (data-driven, one per
* `service_category`) and the nurse builder's category step (with `selected`). Icon in a soft-teal
* disc over the localised label; the whole tile is a button so it's keyboard- and screen-reader-
* accessible. RTL-safe (no directional hard-coding).
* @component CategoryTile
*/
const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false }) => (
<ButtonBase
focusRipple
onClick={onClick}
aria-pressed={selected}
data-selected={selected}
data-category-icon={resolveIcon(iconKey)}
sx={{
width: '100%',
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
textAlign: 'center',
transition: 'border-color 120ms ease, transform 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
'&:active': { transform: 'scale(0.98)' },
}}
>
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{label}
</Typography>
</ButtonBase>
);
export default CategoryTile;
@@ -0,0 +1,2 @@
export { default } from './CategoryTile';
export type { CategoryTileProps } from './CategoryTile';
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl is mocked to echo keys (and honour {count} in estimate_for), locale = en so the money
// util groups with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import PriceDisplay from './PriceDisplay';
function renderPrice(props: React.ComponentProps<typeof PriceDisplay>) {
return render(
<ThemeProvider>
<PriceDisplay {...props} />
</ThemeProvider>,
);
}
describe('<PriceDisplay/> component', () => {
it('renders the price as grouped Toman with the unit label off the price_unit code', () => {
// 2,800,000 IRR = 280,000 Toman, per hour.
renderPrice({ price: '2800000', priceUnit: 'per_hour' });
expect(screen.getByText(/280,000/)).toBeInTheDocument();
expect(screen.getByText('unit_per_hour')).toBeInTheDocument();
});
it('does not render an estimated total from price alone (no sessionCount)', () => {
renderPrice({ price: '2800000', priceUnit: 'per_hour', showEstimate: true });
expect(screen.queryByText('estimate_label', { exact: false })).not.toBeInTheDocument();
});
it('renders the unit-aware estimated total = price × sessionCount when asked', () => {
// 2,800,000 IRR/hr × 6 = 16,800,000 IRR = 1,680,000 Toman.
renderPrice({ price: '2800000', priceUnit: 'per_hour', sessionCount: 6, showEstimate: true });
expect(screen.getByText(/estimate_label/)).toBeInTheDocument();
expect(screen.getByText(/1,680,000/)).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { formatIrrToToman, multiplyIrr } from '@/utils';
import type { PriceUnit } from '@/services/catalog/types';
export interface PriceDisplayProps {
/** IRR Rials as a digit-string (wire shape). Rendered as grouped Toman via the money util. */
price: string;
/** Drives the unit label — an i18n key off the code, **never** a label hardcoded in the component. */
priceUnit: PriceUnit;
/** Duration/count. Required for the estimated total — a total is never derived from `price` alone. */
sessionCount?: number | null;
/** When true and a `sessionCount` is present, also render the unit-aware estimated total line. */
showEstimate?: boolean;
align?: 'start' | 'center';
}
/**
* Renders a variant's price as `{Toman} {unit}` (e.g. «۲۸۰٬۰۰۰ تومان ساعتی») and, when asked, the
* unit-aware estimated total. The bare price is a **unit rate**: the estimated total is only ever
* `price` × `sessionCount`, computed integer-safe (BigInt) — never `price` alone. Money is formatted
* only through the f0 money util; the unit label is an i18n key off `price_unit`.
* @component PriceDisplay
*/
const PriceDisplay: FunctionComponent<PriceDisplayProps> = ({
price,
priceUnit,
sessionCount,
showEstimate = false,
align = 'start',
}) => {
const t = useTranslations('catalog');
const tc = useTranslations('common');
const locale = useLocale();
const amount = formatIrrToToman(price, locale);
const unitLabel = t(`unit_${priceUnit}`);
const hasEstimate = Boolean(showEstimate && sessionCount && sessionCount > 0);
const total = hasEstimate ? formatIrrToToman(multiplyIrr(price, sessionCount as number), locale) : null;
const countLabel = hasEstimate
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(sessionCount as number)
: '';
return (
<Stack sx={{ gap: 0.25, alignItems: align === 'center' ? 'center' : 'flex-start' }}>
<Typography component="p" sx={{ fontWeight: 700 }}>
{amount} {tc('currency_toman')}{' '}
<Typography component="span" variant="body2" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{unitLabel}
</Typography>
</Typography>
{hasEstimate ? (
<Typography variant="caption" sx={{ color: 'var(--bal-primary)', fontWeight: 600 }}>
{t('estimate_label')}: {total} {tc('currency_toman')} ·{' '}
{t('estimate_for', { count: countLabel, unit: t(`count_${priceUnit}`) })}
</Typography>
) : null}
</Stack>
);
};
export default PriceDisplay;
@@ -0,0 +1,2 @@
export { default } from './PriceDisplay';
export type { PriceDisplayProps } from './PriceDisplay';
@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseServiceVariant } from '@/services/catalog/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import VariantCard from './VariantCard';
const baseVariant: NurseServiceVariant = {
id: 1,
serviceCategoryId: 1,
categoryNameFa: 'مراقبت از سالمند',
categoryNameEn: 'Elderly Care',
price: '2800000', // 280,000 Toman
priceUnit: 'per_hour',
sessionCount: null,
displayName: 'Elderly Care · Live-in',
isActive: true,
options: [],
};
function renderCard(variant: NurseServiceVariant, onEdit = jest.fn(), onToggleActive = jest.fn()) {
render(
<ThemeProvider>
<VariantCard variant={variant} onEdit={onEdit} onToggleActive={onToggleActive} />
</ThemeProvider>,
);
return { onEdit, onToggleActive };
}
describe('<VariantCard/> component', () => {
it('renders the display name, category, and price', () => {
renderCard(baseVariant);
expect(screen.getByText('Elderly Care · Live-in')).toBeInTheDocument();
expect(screen.getByText('Elderly Care')).toBeInTheDocument();
expect(screen.getByText(/280,000/)).toBeInTheDocument();
});
it('shows the active chip and the deactivate action for an active variant', () => {
renderCard(baseVariant);
expect(screen.getByText('active_chip')).toBeInTheDocument();
expect(screen.getByText('deactivate')).toBeInTheDocument();
expect(screen.queryByText('inactive_hint')).not.toBeInTheDocument();
});
it('shows the deactivated distinction (chip, hint, reactivate action) for an inactive variant', () => {
renderCard({ ...baseVariant, isActive: false });
expect(screen.getByText('inactive_chip')).toBeInTheDocument();
expect(screen.getByText('inactive_hint')).toBeInTheDocument();
expect(screen.getByText('activate')).toBeInTheDocument();
});
it('never renders a delete affordance', () => {
renderCard(baseVariant);
expect(screen.queryByText('delete')).not.toBeInTheDocument();
});
it('fires onEdit and onToggleActive from the row actions', () => {
const { onEdit, onToggleActive } = renderCard(baseVariant);
fireEvent.click(screen.getByText('edit'));
expect(onEdit).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('deactivate'));
expect(onToggleActive).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,97 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { pickCatalogName } from '@/services/catalog/names';
import type { NurseServiceVariant } from '@/services/catalog/types';
import AppButton from '../common/AppButton';
import StatusChip from '../StatusChip';
import PriceDisplay from '../PriceDisplay';
export interface VariantCardProps {
variant: NurseServiceVariant;
/** Open the builder in edit mode for this variant. */
onEdit: () => void;
/** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. */
onToggleActive: () => void;
}
/**
* One nurse offering (variant) in the services list: its `display_name`, the category, the price via
* the money util (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip +
* an "can't be booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there
* is **no delete affordance**. RTL-safe.
* @component VariantCard
*/
const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onToggleActive }) => {
const t = useTranslations('services');
const locale = useLocale();
const active = variant.isActive;
const categoryName = pickCatalogName(
{ nameFa: variant.categoryNameFa, nameEn: variant.categoryNameEn },
locale,
);
return (
<Paper
elevation={0}
data-active={active}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
// Deactivated rows read as muted/unbookable without hiding their content.
opacity: active ? 1 : 0.66,
}}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{variant.displayName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{categoryName}
</Typography>
</Box>
<StatusChip
status={active ? 'active' : 'neutral'}
label={active ? t('active_chip') : t('inactive_chip')}
sx={{ flexShrink: 0 }}
/>
</Stack>
<PriceDisplay
price={variant.price}
priceUnit={variant.priceUnit}
sessionCount={variant.sessionCount}
showEstimate
/>
{!active ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('inactive_hint')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={onEdit} sx={{ m: 0 }}>
{t('edit')}
</AppButton>
<AppButton
variant="text"
color={active ? 'inherit' : 'primary'}
startIcon={active ? 'visibilityoff' : 'visibilityon'}
onClick={onToggleActive}
sx={{ m: 0 }}
>
{active ? t('deactivate') : t('activate')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
};
export default VariantCard;
@@ -0,0 +1,2 @@
export { default } from './VariantCard';
export type { VariantCardProps } from './VariantCard';
@@ -40,6 +40,14 @@ import WarningIcon from '@mui/icons-material/WarningAmberOutlined';
import LocationIcon from '@mui/icons-material/LocationOnOutlined';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import CoverageIcon from '@mui/icons-material/MapOutlined';
// Catalog — nurse services surface + the customer Home service-category grid (f4/b5)
import ServicesIcon from '@mui/icons-material/LocalOfferOutlined';
import CategoryIcon from '@mui/icons-material/CategoryOutlined';
import ElderlyIcon from '@mui/icons-material/ElderlyOutlined';
import PostSurgeryIcon from '@mui/icons-material/HealingOutlined';
import InfantIcon from '@mui/icons-material/ChildCareOutlined';
import ChronicIcon from '@mui/icons-material/MonitorHeartOutlined';
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismOutlined';
/**
* List of all available Icon names
@@ -95,4 +103,11 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
location: LocationIcon,
delete: DeleteIcon,
coverage: CoverageIcon,
services: ServicesIcon,
category: CategoryIcon,
elderly: ElderlyIcon,
post_surgery: PostSurgeryIcon,
infant: InfantIcon,
chronic: ChronicIcon,
companionship: CompanionshipIcon,
};
+9
View File
@@ -12,6 +12,9 @@ import RelationSelect from './RelationSelect';
import PatientCard from './PatientCard';
import PatientForm from './PatientForm';
import BankStatusPanel from './BankStatusPanel';
import CategoryTile from './CategoryTile';
import PriceDisplay from './PriceDisplay';
import VariantCard from './VariantCard';
export {
UserInfo,
@@ -26,6 +29,9 @@ export {
PatientCard,
PatientForm,
BankStatusPanel,
CategoryTile,
PriceDisplay,
VariantCard,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -38,3 +44,6 @@ export type { RelationSelectProps, RelationOption } from './RelationSelect';
export type { PatientCardProps } from './PatientCard';
export type { PatientFormProps } from './PatientForm';
export type { BankStatusPanelProps } from './BankStatusPanel';
export type { CategoryTileProps } from './CategoryTile';
export type { PriceDisplayProps } from './PriceDisplay';
export type { VariantCardProps } from './VariantCard';
+4
View File
@@ -7,6 +7,8 @@ export const ROUTES = {
HOME: '/',
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
ONBOARDING: '/onboarding',
// Search & discovery — the Home search bar + category tiles navigate here (results built in f6).
SEARCH: '/search',
BOOKINGS: '/bookings',
PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
@@ -17,6 +19,8 @@ export const ROUTES = {
// Nurse app
NURSE: '/nurse',
NURSE_PROFILE: '/nurse/profile',
// Services & prices — the nurse variant builder + offerings list (B7 services half).
NURSE_SERVICES: '/nurse/services',
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
NURSE_COVERAGE: '/nurse/coverage',
NURSE_BANK: '/nurse/bank',
+1
View File
@@ -19,6 +19,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
() => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
@@ -0,0 +1,76 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import type {
CatalogApi,
CreateVariantInput,
NurseServiceVariant,
ServiceCategory,
ServiceOptionGroup,
UpdateVariantInput,
} from '../types';
const CATALOG_BASE = '/api/v1/catalog';
const VARIANTS_BASE = '/api/v1/nurse_variants';
/**
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
* Routes are action-style + snake_case; JSON bodies/fields are camelCase; ids for edit/toggle come
* from the route, never the body. Mutations use POST. The duplicate-listing conflict surfaces as
* `409` `clientFetch` throws `ApiError(409)` (no toast; "other 4xx"), which the builder maps to
* the inline duplicate warning. Selected once USE_CATALOG_MOCK is false.
*
* Pagination binds to the server's `PageSize` property (camelCase `pageSize`, case-insensitive), not
* snake_case `page_size` matching the proven b4 `serviceAreas` client. The domain query param
* `category_id` is snake_case per the contract.
*/
export const catalogClientApi: CatalogApi = {
listCategories: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
query.set('pageSize', String(params?.pageSize ?? CATEGORIES_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${CATALOG_BASE}/categories?${query.toString()}`),
);
},
getCategoryOptionGroups: async (categoryId) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(`${CATALOG_BASE}/option_groups?category_id=${categoryId}`),
),
listMyVariants: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
query.set('pageSize', String(params?.pageSize ?? MY_VARIANTS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<NurseServiceVariant>>>(`${VARIANTS_BASE}/list?${query.toString()}`),
);
},
getVariant: async (id) =>
unwrap(await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/get/${id}`)),
createVariant: async (input: CreateVariantInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/create`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
updateVariant: async (id, input: UpdateVariantInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
setVariantActive: async (id, isActive) => {
await clientFetch<ApiEnvelope<boolean>>(`${VARIANTS_BASE}/set_active/${id}`, {
method: 'POST',
body: JSON.stringify({ isActive }),
});
},
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_CATALOG_MOCK } from '../constants';
import type { CatalogApi } from '../types';
import { catalogClientApi } from './clientApi';
import { catalogMockApi } from './mockApi';
/**
* The selected CatalogApi implementation the single seam the hooks import. Selection is by config
* (USE_CATALOG_MOCK), never by scattered `if (mock)` checks.
*/
export const catalogApi: CatalogApi = USE_CATALOG_MOCK ? catalogMockApi : catalogClientApi;
+208
View File
@@ -0,0 +1,208 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import {
isGroupApplicable,
optionSetSignature,
type CatalogApi,
type CreateVariantInput,
type NurseServiceVariant,
type ServiceCategory,
type ServiceOptionGroup,
type ServiceOptionValue,
type UpdateVariantInput,
type VariantOption,
type VariantOptionSelection,
} from '../types';
import { SEED_CATEGORIES, SEED_OPTION_GROUPS } from './seed';
const MOCK_LATENCY_MS = 250;
const DIGITS_ONLY = /^\d+$/;
const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder - b.sortOrder;
// The nurse's own offerings, seeded **empty** so the offerings empty-state demos; the nurse builds
// variants live (across price units), and the duplicate 409 is reachable by repeating a create.
let store: NurseServiceVariant[] = [];
let nextVariantId = 1;
/** Active-first (contract list order), then most-recent within each group. */
const orderedVariants = () =>
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated<T> {
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? defaultSize;
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
bySortOrder,
);
}
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
return group.values.find((value) => value.id === valueId && value.isActive);
}
/** Builds the denormalised `options` array (group + value labels) from the chosen value ids. */
function buildOptions(groups: ServiceOptionGroup[], selections: VariantOptionSelection[]): VariantOption[] {
return selections
.map((selection) => {
const group = groups.find((candidate) => candidate.id === selection.optionGroupId);
const value = group && findValue(group, selection.optionValueId);
if (!group || !value) return null;
return {
optionGroupId: group.id,
groupNameFa: group.nameFa,
groupNameEn: group.nameEn,
optionValueId: value.id,
valueNameFa: value.nameFa,
valueNameEn: value.nameEn,
} satisfies VariantOption;
})
.filter((option): option is VariantOption => option !== null)
// Present in the group's configured order (mirrors how the server denormalises).
.sort((a, b) => {
const orderOf = (groupId: number) => groups.find((g) => g.id === groupId)?.sortOrder ?? 0;
return orderOf(a.optionGroupId) - orderOf(b.optionGroupId);
});
}
/** Auto-generates `displayName` from the category + chosen value labels (fa primary, contract style). */
function autoDisplayName(category: ServiceCategory, options: VariantOption[]): string {
if (options.length === 0) return category.nameFa;
return [category.nameFa, ...options.map((option) => option.valueNameFa)].join(' · ');
}
/**
* Validates a create request exactly as the server does and throws the matching `ApiError` so the
* builder exercises the real failure copy: 400 for a bad price/unit/session, a missing required
* dimension (named), a value not in its group, a group answered twice, or an unknown/inapplicable
* group/value; 409 for a duplicate identical listing.
*/
function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup[], input: CreateVariantInput): void {
if (typeof input.price !== 'string' || !DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) {
throw new ApiError(400, 'Invalid price', 'invalid_price');
}
if (input.sessionCount != null && (!Number.isInteger(input.sessionCount) || input.sessionCount <= 0)) {
throw new ApiError(400, 'Invalid session count', 'invalid_session_count');
}
const answeredGroupIds = new Set<number>();
for (const selection of input.options) {
const group = groups.find((candidate) => candidate.id === selection.optionGroupId);
if (!group) throw new ApiError(400, 'Unknown or inapplicable option group', 'unknown_group');
if (answeredGroupIds.has(group.id)) throw new ApiError(400, 'A dimension was answered twice', 'duplicate_group');
if (!findValue(group, selection.optionValueId)) {
throw new ApiError(400, 'Value does not belong to its group', 'invalid_value');
}
answeredGroupIds.add(group.id);
}
const missingRequired = groups.find((group) => group.isRequired && !answeredGroupIds.has(group.id));
if (missingRequired) {
throw new ApiError(400, `Missing required dimension: ${missingRequired.nameFa}`, 'missing_required_dimension');
}
const signature = optionSetSignature(
category.id,
input.options.map((option) => option.optionValueId),
);
const duplicate = store.some(
(variant) => optionSetSignature(variant.serviceCategoryId, variant.options.map((o) => o.optionValueId)) === signature,
);
if (duplicate) {
throw new ApiError(409, 'A variant with these details already exists', 'duplicate_listing');
}
}
/**
* In-memory mock behind the CatalogApi seam. Enforces the server's required-dimension validation
* and the `(nurse, category, option-set)` duplicate 409 in-memory, so the builder's inline
* validation + duplicate warning are demonstrable end-to-end. Mirrors the real shapes for a one-line swap.
*/
export const catalogMockApi: CatalogApi = {
listCategories: async (params) => {
await sleep(MOCK_LATENCY_MS);
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
return paginate(active, params, CATEGORIES_PAGE_SIZE);
},
getCategoryOptionGroups: async (categoryId) => {
await sleep(MOCK_LATENCY_MS);
// Return only active values, ordered — mirror the server's projection.
return applicableGroups(categoryId).map((group) => ({
...group,
values: group.values.filter((value) => value.isActive).sort(bySortOrder),
}));
},
listMyVariants: async (params) => {
await sleep(MOCK_LATENCY_MS);
return paginate(orderedVariants(), params, MY_VARIANTS_PAGE_SIZE);
},
getVariant: async (id) => {
await sleep(MOCK_LATENCY_MS);
const variant = store.find((candidate) => candidate.id === id);
if (!variant) throw new ApiError(404, 'Variant not found', 'not_found');
return variant;
},
createVariant: async (input) => {
await sleep(MOCK_LATENCY_MS);
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
const groups = applicableGroups(category.id);
assertValidCreate(category, groups, input);
const options = buildOptions(groups, input.options);
const displayName = input.displayName?.trim() ? input.displayName.trim() : autoDisplayName(category, options);
const variant: NurseServiceVariant = {
id: nextVariantId++,
serviceCategoryId: category.id,
categoryNameFa: category.nameFa,
categoryNameEn: category.nameEn,
price: input.price,
priceUnit: input.priceUnit,
sessionCount: input.sessionCount ?? null,
displayName,
isActive: true,
options,
};
store = [variant, ...store];
return variant;
},
updateVariant: async (id, input: UpdateVariantInput) => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
if (!DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) {
throw new ApiError(400, 'Invalid price', 'invalid_price');
}
// The option-set is immutable on update — only price/unit/session/display change. A blank
// displayName leaves the current one unchanged.
const updated: NurseServiceVariant = {
...existing,
price: input.price,
priceUnit: input.priceUnit,
sessionCount: input.sessionCount ?? null,
displayName: input.displayName?.trim() ? input.displayName.trim() : existing.displayName,
};
store = store.map((candidate) => (candidate.id === id ? updated : candidate));
return updated;
},
setVariantActive: async (id, isActive) => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
},
};
+98
View File
@@ -0,0 +1,98 @@
import type { ServiceCategory, ServiceOptionGroup } from '../types';
/**
* Canned catalog skeleton for the client-side mock. The **categories mirror the b5 seed exactly**
* (ids 15, `sortOrder` 04, `nameFa`/`nameEn` from the contract "Seed" section) so swapping to the
* live endpoint returns the same set. `iconKey`s map to registered AppIcon names (the tile falls
* back gracefully for unknown/missing keys, matching real data whose `iconKey` we don't control).
*
* **Option groups/values are NOT seeded on a fresh real DB** (an admin authors them per category
* see the contract). The mock seeds a representative set anyway so the builder's required/optional
* validation, cross-category groups, and the duplicate-listing 409 are all demonstrable end-to-end.
* This is the one place the mock is intentionally *richer* than a fresh backend; recorded in the
* mock registry. Real data comes from the server; this file is imported nowhere in the production path.
*/
export const SEED_CATEGORIES: ServiceCategory[] = [
{ id: 1, nameFa: 'مراقبت از سالمند', nameEn: 'Elderly Care', descriptionFa: null, descriptionEn: null, iconKey: 'elderly', sortOrder: 0, isActive: true },
{ id: 2, nameFa: 'مراقبت پس از جراحی', nameEn: 'Post-Surgery Recovery', descriptionFa: null, descriptionEn: null, iconKey: 'post_surgery', sortOrder: 1, isActive: true },
{ id: 3, nameFa: 'مراقبت از نوزاد', nameEn: 'Infant Care', descriptionFa: null, descriptionEn: null, iconKey: 'infant', sortOrder: 2, isActive: true },
{ id: 4, nameFa: 'مدیریت بیماری مزمن', nameEn: 'Chronic Illness Management', descriptionFa: null, descriptionEn: null, iconKey: 'chronic', sortOrder: 3, isActive: true },
{ id: 5, nameFa: 'همراهی و مراقبت روزمره', nameEn: 'Companionship', descriptionFa: null, descriptionEn: null, iconKey: 'companionship', sortOrder: 4, isActive: true },
];
/**
* Option groups keyed to categories, plus one cross-category (`serviceCategoryId: null`) group that
* applies to every category. Every row is active; the mock never returns inactive rows (the real
* server filters them). Values carry `isActive` so an inactive value would be dropped downstream.
*/
export const SEED_OPTION_GROUPS: ServiceOptionGroup[] = [
{
id: 11,
serviceCategoryId: 1,
nameFa: 'نوع شیفت',
nameEn: 'Shift type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 101, nameFa: 'روزانه', nameEn: 'Daytime', sortOrder: 1, isActive: true },
{ id: 102, nameFa: 'شبانه', nameEn: 'Overnight', sortOrder: 2, isActive: true },
{ id: 103, nameFa: 'شبانه‌روزی', nameEn: 'Live-in', sortOrder: 3, isActive: true },
],
},
{
id: 12,
serviceCategoryId: 1,
nameFa: 'تعداد بیمار',
nameEn: 'Number of patients',
isRequired: false,
sortOrder: 2,
isActive: true,
values: [
{ id: 111, nameFa: 'یک نفر', nameEn: 'One', sortOrder: 1, isActive: true },
{ id: 112, nameFa: 'دو نفر', nameEn: 'Two', sortOrder: 2, isActive: true },
],
},
{
id: 21,
serviceCategoryId: 2,
nameFa: 'نوع مراقبت',
nameEn: 'Care type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 201, nameFa: 'پانسمان و مراقبت زخم', nameEn: 'Wound care & dressing', sortOrder: 1, isActive: true },
{ id: 202, nameFa: 'تزریقات و سرم', nameEn: 'Injections & IV', sortOrder: 2, isActive: true },
{ id: 203, nameFa: 'فیزیوتراپی سبک', nameEn: 'Light physiotherapy', sortOrder: 3, isActive: true },
],
},
{
id: 31,
serviceCategoryId: 3,
nameFa: 'نوع خدمت',
nameEn: 'Service type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 301, nameFa: 'مراقبت روزانه نوزاد', nameEn: 'Daytime infant care', sortOrder: 1, isActive: true },
{ id: 302, nameFa: 'مراقبت شبانه نوزاد', nameEn: 'Overnight infant care', sortOrder: 2, isActive: true },
],
},
{
// Cross-category: applies to every category (Chronic Illness & Companionship have only this one).
id: 90,
serviceCategoryId: null,
nameFa: 'محل ارائه خدمت',
nameEn: 'Service location',
isRequired: false,
sortOrder: 5,
isActive: true,
values: [
{ id: 901, nameFa: 'منزل', nameEn: 'Home', sortOrder: 1, isActive: true },
{ id: 902, nameFa: 'بیمارستان', nameEn: 'Hospital', sortOrder: 2, isActive: true },
],
},
];
+24
View File
@@ -0,0 +1,24 @@
/**
* When true, the catalog domain is served by the in-memory mock (apis/mockApi.ts) behind the
* CatalogApi seam the b5 `catalog` + `nurse_variants` routes exist, but the mock lets the Home
* grid and the nurse builder (incl. the required-option validation and the duplicate-listing 409)
* demo standalone before the backend is reachable in this environment. Flip to false to hit the
* live endpoints no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_CATALOG_MOCK = true;
/**
* Categories and a category's option groups/values are **admin-seeded reference data** that changes
* rarely, so they are cached **for the whole session** (Infinite `staleTime`) exactly like the geo
* hierarchy fetched once and served from cache across the Home grid and every builder step, never
* refetched per screen. A generous `gcTime` keeps them warm after the last consumer unmounts.
*/
export const CATALOG_REFERENCE_STALE_TIME = Infinity;
export const CATALOG_REFERENCE_GC_TIME = 24 * 60 * 60 * 1000; // 24h
/** The nurse's own offerings change on mutation; keep warm across remounts, invalidate on write. */
export const MY_VARIANTS_STALE_TIME = 60_000;
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
export const CATEGORIES_PAGE_SIZE = 50;
export const MY_VARIANTS_PAGE_SIZE = 50;
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants';
/**
* A category's applicable option groups (its own + every cross-category group), each with its
* values. Reference data cached per category for the whole session, so re-selecting a category in
* the builder never refetches. Disabled until a category is chosen. An **empty list is valid** (a
* category with no dimensions yet), not an error.
*/
export function useCategoryOptionGroups(categoryId: number | null | undefined) {
return useQuery({
queryKey: catalogKeys.categoryOptionGroups(categoryId),
queryFn: () => catalogApi.getCategoryOptionGroups(categoryId as number),
enabled: categoryId != null,
staleTime: CATALOG_REFERENCE_STALE_TIME,
gcTime: CATALOG_REFERENCE_GC_TIME,
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { CreateVariantInput } from '../types';
/**
* Creates a priced variant, then invalidates the nurse's offerings list. A duplicate identical
* listing returns `409` surfaced via `mutation.error` so the builder shows the inline
* duplicate-listing warning (never a generic toast). A missing required dimension / bad price
* returns `400`, likewise surfaced inline.
*/
export function useCreateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateVariantInput) => catalogApi.createVariant(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import type { PageParams } from '@/lib/api/types';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { MY_VARIANTS_STALE_TIME } from '../constants';
/**
* The signed-in nurse's own offerings (active and inactive, active-first, paginated). Self-scoped
* server-side a nurse never sees another nurse's variants. A deliberate staleTime keeps the list
* warm across remounts; every variant mutation invalidates it.
*/
export function useMyVariants(params?: PageParams) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: catalogKeys.myVariants(params),
queryFn: () => catalogApi.listMyVariants(params),
enabled: isAuthenticated,
staleTime: MY_VARIANTS_STALE_TIME,
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import type { PageParams } from '@/lib/api/types';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants';
/**
* The active service categories (ordered by `sortOrder`). Admin-seeded **reference data** cached
* for the whole session (Infinite `staleTime`), so the Home grid and the builder's step 1 populate
* once and are served from cache on every revisit. Public (no auth) always enabled.
*/
export function useServiceCategories(params?: PageParams) {
return useQuery({
queryKey: catalogKeys.categories(),
queryFn: () => catalogApi.listCategories(params),
staleTime: CATALOG_REFERENCE_STALE_TIME,
gcTime: CATALOG_REFERENCE_GC_TIME,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
/**
* Soft-deactivates or reactivates a variant (`set_active`) **never a hard delete**. A deactivated
* variant is unbookable and drops out of search. The offerings list shows inactive rows, so this
* hook drives both the deactivate-with-confirm action and the reactivate affordance on an inactive
* row. On success it invalidates the offerings list so the row flips to its new visual state.
*/
export function useSetVariantActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setVariantActive(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { NurseServiceVariant, UpdateVariantInput } from '../types';
/**
* Edits a variant's price/unit/session/display (the option-set is immutable). On success it primes
* the single-variant cache with the returned row and invalidates the offerings list the edited
* row reflects immediately without a wasteful full refetch of every page.
*/
export function useUpdateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateVariantInput }) => catalogApi.updateVariant(id, input),
onSuccess: (variant: NurseServiceVariant) => {
queryClient.setQueryData(catalogKeys.variant(variant.id), variant);
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
+6
View File
@@ -0,0 +1,6 @@
export { useServiceCategories } from './hooks/useServiceCategories';
export { useCategoryOptionGroups } from './hooks/useCategoryOptionGroups';
export { useMyVariants } from './hooks/useMyVariants';
export { useCreateVariant } from './hooks/useCreateVariant';
export { useUpdateVariant } from './hooks/useUpdateVariant';
export { useSetVariantActive } from './hooks/useSetVariantActive';
+22
View File
@@ -0,0 +1,22 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the catalog domain. Reference data (categories, a category's option
* groups) is keyed so each is fetched **once** per session and served from cache across the Home
* grid and the nurse builder. The nurse's own variant list invalidates on every mutation via the
* `myVariantsLists()` prefix; a single edited variant is refreshed by `variant(id)`.
*/
export const catalogKeys = {
all: ['catalog'] as const,
// Reference data — cached for the whole session, never invalidated by this phase.
categories: () => [...catalogKeys.all, 'categories'] as const,
categoryOptionGroups: (categoryId?: number | null) =>
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
variants: () => [...catalogKeys.all, 'variants'] as const,
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
};
+10
View File
@@ -0,0 +1,10 @@
import type { CatalogName } from './types';
/**
* Picks the locale-appropriate name for any catalog row (category, option group, option value).
* `fa` is the default locale; any non-`en` locale reads the Persian name. Kept here (not in a
* component) so the Home grid, the builder, and later search all label catalog rows identically.
*/
export function pickCatalogName(row: CatalogName, locale: string): string {
return locale === 'en' ? row.nameEn : row.nameFa;
}
+158
View File
@@ -0,0 +1,158 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Catalog domain the admin-seeded skeleton (categories option groups option values) and
* the nurse pricing layer (variants the atomic bookable unit). Shapes mirror the b5 contract
* (`dev/contracts/domains/catalog.md`) exactly. The wire is **camelCase** (`nameFa`/`serviceCategoryId`/
* `priceUnit`), not the snake_case the routing convention implies the swagger snapshot is the source
* of truth.
*
* Load-bearing semantics (see the contract "Key semantics"):
* - The bookable unit is the **variant**, never the nurse or the category.
* - `price` is **IRR Rials, integer, a string of digits** (e.g. `"2800000"`) never a float, never Toman.
* The engagement total is `price` + `priceUnit` + `sessionCount`, never `price` alone.
* - A `serviceCategoryId = null` option group is **cross-category** it applies to every category.
* - Categories/option groups/values/variants are **reference data + soft-deactivated**, never hard-deleted.
*/
/** The five price units a variant's `price` may be quoted in (closed enum; the only hardcoded catalog set). */
export type PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h';
/** Ordered for the price-unit select; labels are i18n keys off the code (never derived from the code). */
export const PRICE_UNITS: readonly PriceUnit[] = [
'per_hour',
'per_session',
'per_half_day',
'per_day',
'per_24h',
] as const;
/** Fields every localisable catalog row carries — `nameFa` is primary, the client picks by locale. */
export interface CatalogName {
nameFa: string;
nameEn: string;
}
/** `ServiceCategoryDto` — a top-level service category (Elderly Care, Post-Surgery Recovery, …). */
export interface ServiceCategory extends CatalogName {
id: number;
descriptionFa: string | null;
descriptionEn: string | null;
/** Optional icon hint; the tile maps known keys to a registered icon and falls back gracefully. */
iconKey: string | null;
sortOrder: number;
isActive: boolean;
}
/** `OptionValueDto` — a concrete answer within an option group (e.g. شبانه‌روزی / Live-in). */
export interface ServiceOptionValue extends CatalogName {
id: number;
sortOrder: number;
isActive: boolean;
}
/**
* `OptionGroupDto` a configurable dimension for a category (e.g. نوع شیفت / Shift type).
* `serviceCategoryId = null` **cross-category** (applies to every category). `isRequired` groups
* must be answered (one value) before a variant can be created.
*/
export interface ServiceOptionGroup extends CatalogName {
id: number;
serviceCategoryId: number | null;
isRequired: boolean;
sortOrder: number;
isActive: boolean;
values: ServiceOptionValue[];
}
/** `VariantOptionDto` — one answered dimension on a variant (denormalised group + value labels). */
export interface VariantOption {
optionGroupId: number;
groupNameFa: string;
groupNameEn: string;
optionValueId: number;
valueNameFa: string;
valueNameEn: string;
}
/** `VariantDto` — a nurse's priced offering: category + chosen option values + own price + unit. */
export interface NurseServiceVariant {
id: number;
serviceCategoryId: number;
categoryNameFa: string;
categoryNameEn: string;
/** IRR Rials as a string of digits (money-and-types.md). Rendered only via the money util. */
price: string;
priceUnit: PriceUnit;
sessionCount: number | null;
displayName: string;
isActive: boolean;
options: VariantOption[];
}
/** One answered dimension in a create request — one value per group (mirrors the server UNIQUE). */
export interface VariantOptionSelection {
optionGroupId: number;
optionValueId: number;
}
/**
* `nurse_variants/create` body. `price` is the IRR digit-string (converted from Toman at the field
* boundary). Omit `displayName` to let the server auto-generate it from the category + value labels.
*/
export interface CreateVariantInput {
serviceCategoryId: number;
options: VariantOptionSelection[];
price: string;
priceUnit: PriceUnit;
sessionCount?: number | null;
displayName?: string | null;
}
/**
* `nurse_variants/update/{id}` body edits price/unit/session/display only. The option-set is
* **immutable** on update (changing dimensions = create-new + deactivate-old). A blank `displayName`
* leaves the current one unchanged.
*/
export interface UpdateVariantInput {
price: string;
priceUnit: PriceUnit;
sessionCount?: number | null;
displayName?: string | null;
}
/**
* The catalog domain's API seam the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
*/
export interface CatalogApi {
/** Public reference data — active categories, ordered by `sortOrder`, paginated + cached. */
listCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
/** A category's applicable option groups (its own + every cross-category group), with values. Cached. */
getCategoryOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
/** The signed-in nurse's own offerings — active and inactive, active-first, paginated. */
listMyVariants(params?: PageParams): Promise<Paginated<NurseServiceVariant>>;
getVariant(id: number): Promise<NurseServiceVariant>;
createVariant(input: CreateVariantInput): Promise<NurseServiceVariant>;
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
/** Soft deactivate/reactivate — never a hard delete. */
setVariantActive(id: number, isActive: boolean): Promise<void>;
}
/**
* The applicable option groups for a category = its own groups **plus** every cross-category
* (`serviceCategoryId === null`) group. The server already returns exactly this set from
* `getCategoryOptionGroups`; this predicate documents the rule and is used by the mock.
*/
export function isGroupApplicable(group: ServiceOptionGroup, categoryId: number): boolean {
return group.serviceCategoryId === null || group.serviceCategoryId === categoryId;
}
/**
* A stable, order-independent signature of a variant's answered option-set, used for the
* duplicate-listing guard (same nurse + same category + identical option-set 409). The server's
* uniqueness is the source of truth; this lets the mock reproduce the 409 and the builder pre-warn.
*/
export function optionSetSignature(categoryId: number, valueIds: number[]): string {
return `${categoryId}:${[...valueIds].sort((a, b) => a - b).join(',')}`;
}
+28 -1
View File
@@ -1,4 +1,4 @@
import { parseIrr, rialToToman, formatIrr, formatIrrToToman } from './money';
import { parseIrr, rialToToman, tomanToRial, multiplyIrr, formatIrr, formatIrrToToman } from './money';
describe('money utils', () => {
describe('parseIrr', () => {
@@ -29,6 +29,33 @@ describe('money utils', () => {
});
});
describe('tomanToRial', () => {
it('multiplies Toman by 10 into an IRR digit-string', () => {
expect(tomanToRial('280000')).toBe('2800000');
expect(tomanToRial(280000)).toBe('2800000');
});
it('stays integer-safe for large Toman amounts', () => {
expect(tomanToRial('900719925474099')).toBe('9007199254740990');
});
it('rejects non-integer input', () => {
expect(() => tomanToRial('12.5')).toThrow();
});
});
describe('multiplyIrr', () => {
it('multiplies an IRR amount by an integer count', () => {
// 280,000 Toman/hr = 2,800,000 IRR × 6 hours = 16,800,000 IRR.
expect(multiplyIrr('2800000', 6)).toBe('16800000');
});
it('rejects a negative or non-integer count', () => {
expect(() => multiplyIrr('2800000', -1)).toThrow();
expect(() => multiplyIrr('2800000', 1.5)).toThrow();
});
});
describe('formatting', () => {
it('groups Rials in en locale', () => {
expect(formatIrr('23300000', 'en')).toBe('23,300,000');
+20
View File
@@ -27,6 +27,26 @@ export function rialToToman(value: string | number | bigint): bigint {
return parseIrr(value) / RIALS_PER_TOMAN;
}
/**
* Converts a whole-Toman amount (what the nurse types in the price field) to the IRR Rial
* digit-string the wire expects (×10). This is the sanctioned TomanRial conversion at the
* *field boundary* (money-and-types.md): the UI collects Toman, the contract carries Rials.
* Integer-safe (BigInt); never a float on the price path.
*/
export function tomanToRial(value: string | number | bigint): string {
return String(parseIrr(value) * RIALS_PER_TOMAN);
}
/**
* Multiplies an IRR Rial amount by an integer count, returning the IRR digit-string. Used for
* the builder's unit-aware estimated total (`price` × `session_count`) a total is only ever
* `price` interpreted by its `price_unit` and multiplied by the count, never `price` alone.
*/
export function multiplyIrr(value: string | number | bigint, count: number): string {
if (!Number.isInteger(count) || count < 0) throw new Error(`count must be a non-negative integer, got ${count}`);
return String(parseIrr(value) * BigInt(count));
}
/** Formats a grouped Rial amount (no unit). `fa` uses Persian digits. */
export function formatIrr(value: string | number | bigint, locale: string = 'fa'): string {
return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(parseIrr(value));
@@ -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.