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
@@ -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';