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