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
@@ -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(' ')}
/>
);
}