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:
@@ -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';
|
||||
Reference in New Issue
Block a user