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