99ebf5d881
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>
211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
'use client';
|
|
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
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;
|
|
title: string;
|
|
body: string;
|
|
ctaLabel: string;
|
|
to: string;
|
|
}
|
|
|
|
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
|
>
|
|
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
|
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
|
<Stack sx={{ gap: 0.25 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
{title}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{body}
|
|
</Typography>
|
|
</Stack>
|
|
<AppButton color="primary" variant="outlined" to={to} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
|
{ctaLabel}
|
|
</AppButton>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
|
|
/**
|
|
* 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');
|
|
const router = useRouter();
|
|
const locale = useLocale();
|
|
|
|
const { data: me } = useMe();
|
|
const { data } = usePatients();
|
|
|
|
const isEmpty = data?.total === 0;
|
|
|
|
useEffect(() => {
|
|
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
|
}, [isEmpty, router, locale]);
|
|
|
|
if (data == null || isEmpty) {
|
|
return <AppLoading />;
|
|
}
|
|
|
|
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 }}>
|
|
<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"
|
|
title={t('nudge_patient_title')}
|
|
body={t('nudge_patient_body')}
|
|
ctaLabel={t('nudge_patient_cta')}
|
|
to={href(ROUTES.PATIENTS)}
|
|
/>
|
|
{!profileComplete ? (
|
|
<NudgeCard
|
|
icon="profile"
|
|
title={t('nudge_profile_title')}
|
|
body={t('nudge_profile_body')}
|
|
ctaLabel={t('nudge_profile_cta')}
|
|
to={href(ROUTES.PROFILE)}
|
|
/>
|
|
) : null}
|
|
</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>
|
|
);
|
|
};
|