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,76 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import type {
CatalogApi,
CreateVariantInput,
NurseServiceVariant,
ServiceCategory,
ServiceOptionGroup,
UpdateVariantInput,
} from '../types';
const CATALOG_BASE = '/api/v1/catalog';
const VARIANTS_BASE = '/api/v1/nurse_variants';
/**
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
* Routes are action-style + snake_case; JSON bodies/fields are camelCase; ids for edit/toggle come
* from the route, never the body. Mutations use POST. The duplicate-listing conflict surfaces as
* `409` — `clientFetch` throws `ApiError(409)` (no toast; "other 4xx"), which the builder maps to
* the inline duplicate warning. Selected once USE_CATALOG_MOCK is false.
*
* Pagination binds to the server's `PageSize` property (camelCase `pageSize`, case-insensitive), not
* snake_case `page_size` — matching the proven b4 `serviceAreas` client. The domain query param
* `category_id` is snake_case per the contract.
*/
export const catalogClientApi: CatalogApi = {
listCategories: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
query.set('pageSize', String(params?.pageSize ?? CATEGORIES_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${CATALOG_BASE}/categories?${query.toString()}`),
);
},
getCategoryOptionGroups: async (categoryId) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(`${CATALOG_BASE}/option_groups?category_id=${categoryId}`),
),
listMyVariants: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
query.set('pageSize', String(params?.pageSize ?? MY_VARIANTS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<NurseServiceVariant>>>(`${VARIANTS_BASE}/list?${query.toString()}`),
);
},
getVariant: async (id) =>
unwrap(await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/get/${id}`)),
createVariant: async (input: CreateVariantInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/create`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
updateVariant: async (id, input: UpdateVariantInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceVariant>>(`${VARIANTS_BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
setVariantActive: async (id, isActive) => {
await clientFetch<ApiEnvelope<boolean>>(`${VARIANTS_BASE}/set_active/${id}`, {
method: 'POST',
body: JSON.stringify({ isActive }),
});
},
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_CATALOG_MOCK } from '../constants';
import type { CatalogApi } from '../types';
import { catalogClientApi } from './clientApi';
import { catalogMockApi } from './mockApi';
/**
* The selected CatalogApi implementation — the single seam the hooks import. Selection is by config
* (USE_CATALOG_MOCK), never by scattered `if (mock)` checks.
*/
export const catalogApi: CatalogApi = USE_CATALOG_MOCK ? catalogMockApi : catalogClientApi;
+208
View File
@@ -0,0 +1,208 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import {
isGroupApplicable,
optionSetSignature,
type CatalogApi,
type CreateVariantInput,
type NurseServiceVariant,
type ServiceCategory,
type ServiceOptionGroup,
type ServiceOptionValue,
type UpdateVariantInput,
type VariantOption,
type VariantOptionSelection,
} from '../types';
import { SEED_CATEGORIES, SEED_OPTION_GROUPS } from './seed';
const MOCK_LATENCY_MS = 250;
const DIGITS_ONLY = /^\d+$/;
const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder - b.sortOrder;
// The nurse's own offerings, seeded **empty** so the offerings empty-state demos; the nurse builds
// variants live (across price units), and the duplicate 409 is reachable by repeating a create.
let store: NurseServiceVariant[] = [];
let nextVariantId = 1;
/** Active-first (contract list order), then most-recent within each group. */
const orderedVariants = () =>
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated<T> {
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? defaultSize;
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
bySortOrder,
);
}
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
return group.values.find((value) => value.id === valueId && value.isActive);
}
/** Builds the denormalised `options` array (group + value labels) from the chosen value ids. */
function buildOptions(groups: ServiceOptionGroup[], selections: VariantOptionSelection[]): VariantOption[] {
return selections
.map((selection) => {
const group = groups.find((candidate) => candidate.id === selection.optionGroupId);
const value = group && findValue(group, selection.optionValueId);
if (!group || !value) return null;
return {
optionGroupId: group.id,
groupNameFa: group.nameFa,
groupNameEn: group.nameEn,
optionValueId: value.id,
valueNameFa: value.nameFa,
valueNameEn: value.nameEn,
} satisfies VariantOption;
})
.filter((option): option is VariantOption => option !== null)
// Present in the group's configured order (mirrors how the server denormalises).
.sort((a, b) => {
const orderOf = (groupId: number) => groups.find((g) => g.id === groupId)?.sortOrder ?? 0;
return orderOf(a.optionGroupId) - orderOf(b.optionGroupId);
});
}
/** Auto-generates `displayName` from the category + chosen value labels (fa primary, contract style). */
function autoDisplayName(category: ServiceCategory, options: VariantOption[]): string {
if (options.length === 0) return category.nameFa;
return [category.nameFa, ...options.map((option) => option.valueNameFa)].join(' · ');
}
/**
* Validates a create request exactly as the server does and throws the matching `ApiError` so the
* builder exercises the real failure copy: 400 for a bad price/unit/session, a missing required
* dimension (named), a value not in its group, a group answered twice, or an unknown/inapplicable
* group/value; 409 for a duplicate identical listing.
*/
function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup[], input: CreateVariantInput): void {
if (typeof input.price !== 'string' || !DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) {
throw new ApiError(400, 'Invalid price', 'invalid_price');
}
if (input.sessionCount != null && (!Number.isInteger(input.sessionCount) || input.sessionCount <= 0)) {
throw new ApiError(400, 'Invalid session count', 'invalid_session_count');
}
const answeredGroupIds = new Set<number>();
for (const selection of input.options) {
const group = groups.find((candidate) => candidate.id === selection.optionGroupId);
if (!group) throw new ApiError(400, 'Unknown or inapplicable option group', 'unknown_group');
if (answeredGroupIds.has(group.id)) throw new ApiError(400, 'A dimension was answered twice', 'duplicate_group');
if (!findValue(group, selection.optionValueId)) {
throw new ApiError(400, 'Value does not belong to its group', 'invalid_value');
}
answeredGroupIds.add(group.id);
}
const missingRequired = groups.find((group) => group.isRequired && !answeredGroupIds.has(group.id));
if (missingRequired) {
throw new ApiError(400, `Missing required dimension: ${missingRequired.nameFa}`, 'missing_required_dimension');
}
const signature = optionSetSignature(
category.id,
input.options.map((option) => option.optionValueId),
);
const duplicate = store.some(
(variant) => optionSetSignature(variant.serviceCategoryId, variant.options.map((o) => o.optionValueId)) === signature,
);
if (duplicate) {
throw new ApiError(409, 'A variant with these details already exists', 'duplicate_listing');
}
}
/**
* In-memory mock behind the CatalogApi seam. Enforces the server's required-dimension validation
* and the `(nurse, category, option-set)` duplicate 409 in-memory, so the builder's inline
* validation + duplicate warning are demonstrable end-to-end. Mirrors the real shapes for a one-line swap.
*/
export const catalogMockApi: CatalogApi = {
listCategories: async (params) => {
await sleep(MOCK_LATENCY_MS);
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
return paginate(active, params, CATEGORIES_PAGE_SIZE);
},
getCategoryOptionGroups: async (categoryId) => {
await sleep(MOCK_LATENCY_MS);
// Return only active values, ordered — mirror the server's projection.
return applicableGroups(categoryId).map((group) => ({
...group,
values: group.values.filter((value) => value.isActive).sort(bySortOrder),
}));
},
listMyVariants: async (params) => {
await sleep(MOCK_LATENCY_MS);
return paginate(orderedVariants(), params, MY_VARIANTS_PAGE_SIZE);
},
getVariant: async (id) => {
await sleep(MOCK_LATENCY_MS);
const variant = store.find((candidate) => candidate.id === id);
if (!variant) throw new ApiError(404, 'Variant not found', 'not_found');
return variant;
},
createVariant: async (input) => {
await sleep(MOCK_LATENCY_MS);
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
const groups = applicableGroups(category.id);
assertValidCreate(category, groups, input);
const options = buildOptions(groups, input.options);
const displayName = input.displayName?.trim() ? input.displayName.trim() : autoDisplayName(category, options);
const variant: NurseServiceVariant = {
id: nextVariantId++,
serviceCategoryId: category.id,
categoryNameFa: category.nameFa,
categoryNameEn: category.nameEn,
price: input.price,
priceUnit: input.priceUnit,
sessionCount: input.sessionCount ?? null,
displayName,
isActive: true,
options,
};
store = [variant, ...store];
return variant;
},
updateVariant: async (id, input: UpdateVariantInput) => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
if (!DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) {
throw new ApiError(400, 'Invalid price', 'invalid_price');
}
// The option-set is immutable on update — only price/unit/session/display change. A blank
// displayName leaves the current one unchanged.
const updated: NurseServiceVariant = {
...existing,
price: input.price,
priceUnit: input.priceUnit,
sessionCount: input.sessionCount ?? null,
displayName: input.displayName?.trim() ? input.displayName.trim() : existing.displayName,
};
store = store.map((candidate) => (candidate.id === id ? updated : candidate));
return updated;
},
setVariantActive: async (id, isActive) => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
},
};
+98
View File
@@ -0,0 +1,98 @@
import type { ServiceCategory, ServiceOptionGroup } from '../types';
/**
* Canned catalog skeleton for the client-side mock. The **categories mirror the b5 seed exactly**
* (ids 15, `sortOrder` 04, `nameFa`/`nameEn` from the contract "Seed" section) so swapping to the
* live endpoint returns the same set. `iconKey`s map to registered AppIcon names (the tile falls
* back gracefully for unknown/missing keys, matching real data whose `iconKey` we don't control).
*
* **Option groups/values are NOT seeded on a fresh real DB** (an admin authors them per category —
* see the contract). The mock seeds a representative set anyway so the builder's required/optional
* validation, cross-category groups, and the duplicate-listing 409 are all demonstrable end-to-end.
* This is the one place the mock is intentionally *richer* than a fresh backend; recorded in the
* mock registry. Real data comes from the server; this file is imported nowhere in the production path.
*/
export const SEED_CATEGORIES: ServiceCategory[] = [
{ id: 1, nameFa: 'مراقبت از سالمند', nameEn: 'Elderly Care', descriptionFa: null, descriptionEn: null, iconKey: 'elderly', sortOrder: 0, isActive: true },
{ id: 2, nameFa: 'مراقبت پس از جراحی', nameEn: 'Post-Surgery Recovery', descriptionFa: null, descriptionEn: null, iconKey: 'post_surgery', sortOrder: 1, isActive: true },
{ id: 3, nameFa: 'مراقبت از نوزاد', nameEn: 'Infant Care', descriptionFa: null, descriptionEn: null, iconKey: 'infant', sortOrder: 2, isActive: true },
{ id: 4, nameFa: 'مدیریت بیماری مزمن', nameEn: 'Chronic Illness Management', descriptionFa: null, descriptionEn: null, iconKey: 'chronic', sortOrder: 3, isActive: true },
{ id: 5, nameFa: 'همراهی و مراقبت روزمره', nameEn: 'Companionship', descriptionFa: null, descriptionEn: null, iconKey: 'companionship', sortOrder: 4, isActive: true },
];
/**
* Option groups keyed to categories, plus one cross-category (`serviceCategoryId: null`) group that
* applies to every category. Every row is active; the mock never returns inactive rows (the real
* server filters them). Values carry `isActive` so an inactive value would be dropped downstream.
*/
export const SEED_OPTION_GROUPS: ServiceOptionGroup[] = [
{
id: 11,
serviceCategoryId: 1,
nameFa: 'نوع شیفت',
nameEn: 'Shift type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 101, nameFa: 'روزانه', nameEn: 'Daytime', sortOrder: 1, isActive: true },
{ id: 102, nameFa: 'شبانه', nameEn: 'Overnight', sortOrder: 2, isActive: true },
{ id: 103, nameFa: 'شبانه‌روزی', nameEn: 'Live-in', sortOrder: 3, isActive: true },
],
},
{
id: 12,
serviceCategoryId: 1,
nameFa: 'تعداد بیمار',
nameEn: 'Number of patients',
isRequired: false,
sortOrder: 2,
isActive: true,
values: [
{ id: 111, nameFa: 'یک نفر', nameEn: 'One', sortOrder: 1, isActive: true },
{ id: 112, nameFa: 'دو نفر', nameEn: 'Two', sortOrder: 2, isActive: true },
],
},
{
id: 21,
serviceCategoryId: 2,
nameFa: 'نوع مراقبت',
nameEn: 'Care type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 201, nameFa: 'پانسمان و مراقبت زخم', nameEn: 'Wound care & dressing', sortOrder: 1, isActive: true },
{ id: 202, nameFa: 'تزریقات و سرم', nameEn: 'Injections & IV', sortOrder: 2, isActive: true },
{ id: 203, nameFa: 'فیزیوتراپی سبک', nameEn: 'Light physiotherapy', sortOrder: 3, isActive: true },
],
},
{
id: 31,
serviceCategoryId: 3,
nameFa: 'نوع خدمت',
nameEn: 'Service type',
isRequired: true,
sortOrder: 1,
isActive: true,
values: [
{ id: 301, nameFa: 'مراقبت روزانه نوزاد', nameEn: 'Daytime infant care', sortOrder: 1, isActive: true },
{ id: 302, nameFa: 'مراقبت شبانه نوزاد', nameEn: 'Overnight infant care', sortOrder: 2, isActive: true },
],
},
{
// Cross-category: applies to every category (Chronic Illness & Companionship have only this one).
id: 90,
serviceCategoryId: null,
nameFa: 'محل ارائه خدمت',
nameEn: 'Service location',
isRequired: false,
sortOrder: 5,
isActive: true,
values: [
{ id: 901, nameFa: 'منزل', nameEn: 'Home', sortOrder: 1, isActive: true },
{ id: 902, nameFa: 'بیمارستان', nameEn: 'Hospital', sortOrder: 2, isActive: true },
],
},
];
+24
View File
@@ -0,0 +1,24 @@
/**
* When true, the catalog domain is served by the in-memory mock (apis/mockApi.ts) behind the
* CatalogApi seam — the b5 `catalog` + `nurse_variants` routes exist, but the mock lets the Home
* grid and the nurse builder (incl. the required-option validation and the duplicate-listing 409)
* demo standalone before the backend is reachable in this environment. Flip to false to hit the
* live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_CATALOG_MOCK = true;
/**
* Categories and a category's option groups/values are **admin-seeded reference data** that changes
* rarely, so they are cached **for the whole session** (Infinite `staleTime`) exactly like the geo
* hierarchy — fetched once and served from cache across the Home grid and every builder step, never
* refetched per screen. A generous `gcTime` keeps them warm after the last consumer unmounts.
*/
export const CATALOG_REFERENCE_STALE_TIME = Infinity;
export const CATALOG_REFERENCE_GC_TIME = 24 * 60 * 60 * 1000; // 24h
/** The nurse's own offerings change on mutation; keep warm across remounts, invalidate on write. */
export const MY_VARIANTS_STALE_TIME = 60_000;
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
export const CATEGORIES_PAGE_SIZE = 50;
export const MY_VARIANTS_PAGE_SIZE = 50;
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants';
/**
* A category's applicable option groups (its own + every cross-category group), each with its
* values. Reference data — cached per category for the whole session, so re-selecting a category in
* the builder never refetches. Disabled until a category is chosen. An **empty list is valid** (a
* category with no dimensions yet), not an error.
*/
export function useCategoryOptionGroups(categoryId: number | null | undefined) {
return useQuery({
queryKey: catalogKeys.categoryOptionGroups(categoryId),
queryFn: () => catalogApi.getCategoryOptionGroups(categoryId as number),
enabled: categoryId != null,
staleTime: CATALOG_REFERENCE_STALE_TIME,
gcTime: CATALOG_REFERENCE_GC_TIME,
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { CreateVariantInput } from '../types';
/**
* Creates a priced variant, then invalidates the nurse's offerings list. A duplicate identical
* listing returns `409` — surfaced via `mutation.error` so the builder shows the inline
* duplicate-listing warning (never a generic toast). A missing required dimension / bad price
* returns `400`, likewise surfaced inline.
*/
export function useCreateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateVariantInput) => catalogApi.createVariant(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import type { PageParams } from '@/lib/api/types';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { MY_VARIANTS_STALE_TIME } from '../constants';
/**
* The signed-in nurse's own offerings (active and inactive, active-first, paginated). Self-scoped
* server-side — a nurse never sees another nurse's variants. A deliberate staleTime keeps the list
* warm across remounts; every variant mutation invalidates it.
*/
export function useMyVariants(params?: PageParams) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: catalogKeys.myVariants(params),
queryFn: () => catalogApi.listMyVariants(params),
enabled: isAuthenticated,
staleTime: MY_VARIANTS_STALE_TIME,
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import type { PageParams } from '@/lib/api/types';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants';
/**
* The active service categories (ordered by `sortOrder`). Admin-seeded **reference data** — cached
* for the whole session (Infinite `staleTime`), so the Home grid and the builder's step 1 populate
* once and are served from cache on every revisit. Public (no auth) — always enabled.
*/
export function useServiceCategories(params?: PageParams) {
return useQuery({
queryKey: catalogKeys.categories(),
queryFn: () => catalogApi.listCategories(params),
staleTime: CATALOG_REFERENCE_STALE_TIME,
gcTime: CATALOG_REFERENCE_GC_TIME,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
/**
* Soft-deactivates or reactivates a variant (`set_active`) — **never a hard delete**. A deactivated
* variant is unbookable and drops out of search. The offerings list shows inactive rows, so this
* hook drives both the deactivate-with-confirm action and the reactivate affordance on an inactive
* row. On success it invalidates the offerings list so the row flips to its new visual state.
*/
export function useSetVariantActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setVariantActive(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { NurseServiceVariant, UpdateVariantInput } from '../types';
/**
* Edits a variant's price/unit/session/display (the option-set is immutable). On success it primes
* the single-variant cache with the returned row and invalidates the offerings list — the edited
* row reflects immediately without a wasteful full refetch of every page.
*/
export function useUpdateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateVariantInput }) => catalogApi.updateVariant(id, input),
onSuccess: (variant: NurseServiceVariant) => {
queryClient.setQueryData(catalogKeys.variant(variant.id), variant);
queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() });
},
});
}
+6
View File
@@ -0,0 +1,6 @@
export { useServiceCategories } from './hooks/useServiceCategories';
export { useCategoryOptionGroups } from './hooks/useCategoryOptionGroups';
export { useMyVariants } from './hooks/useMyVariants';
export { useCreateVariant } from './hooks/useCreateVariant';
export { useUpdateVariant } from './hooks/useUpdateVariant';
export { useSetVariantActive } from './hooks/useSetVariantActive';
+22
View File
@@ -0,0 +1,22 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the catalog domain. Reference data (categories, a category's option
* groups) is keyed so each is fetched **once** per session and served from cache across the Home
* grid and the nurse builder. The nurse's own variant list invalidates on every mutation via the
* `myVariantsLists()` prefix; a single edited variant is refreshed by `variant(id)`.
*/
export const catalogKeys = {
all: ['catalog'] as const,
// Reference data — cached for the whole session, never invalidated by this phase.
categories: () => [...catalogKeys.all, 'categories'] as const,
categoryOptionGroups: (categoryId?: number | null) =>
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
variants: () => [...catalogKeys.all, 'variants'] as const,
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
};
+10
View File
@@ -0,0 +1,10 @@
import type { CatalogName } from './types';
/**
* Picks the locale-appropriate name for any catalog row (category, option group, option value).
* `fa` is the default locale; any non-`en` locale reads the Persian name. Kept here (not in a
* component) so the Home grid, the builder, and later search all label catalog rows identically.
*/
export function pickCatalogName(row: CatalogName, locale: string): string {
return locale === 'en' ? row.nameEn : row.nameFa;
}
+158
View File
@@ -0,0 +1,158 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Catalog domain — the admin-seeded skeleton (categories → option groups → option values) and
* the nurse pricing layer (variants — the atomic bookable unit). Shapes mirror the b5 contract
* (`dev/contracts/domains/catalog.md`) exactly. The wire is **camelCase** (`nameFa`/`serviceCategoryId`/
* `priceUnit`), not the snake_case the routing convention implies — the swagger snapshot is the source
* of truth.
*
* Load-bearing semantics (see the contract "Key semantics"):
* - The bookable unit is the **variant**, never the nurse or the category.
* - `price` is **IRR Rials, integer, a string of digits** (e.g. `"2800000"`) — never a float, never Toman.
* The engagement total is `price` + `priceUnit` + `sessionCount`, never `price` alone.
* - A `serviceCategoryId = null` option group is **cross-category** — it applies to every category.
* - Categories/option groups/values/variants are **reference data + soft-deactivated**, never hard-deleted.
*/
/** The five price units a variant's `price` may be quoted in (closed enum; the only hardcoded catalog set). */
export type PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h';
/** Ordered for the price-unit select; labels are i18n keys off the code (never derived from the code). */
export const PRICE_UNITS: readonly PriceUnit[] = [
'per_hour',
'per_session',
'per_half_day',
'per_day',
'per_24h',
] as const;
/** Fields every localisable catalog row carries — `nameFa` is primary, the client picks by locale. */
export interface CatalogName {
nameFa: string;
nameEn: string;
}
/** `ServiceCategoryDto` — a top-level service category (Elderly Care, Post-Surgery Recovery, …). */
export interface ServiceCategory extends CatalogName {
id: number;
descriptionFa: string | null;
descriptionEn: string | null;
/** Optional icon hint; the tile maps known keys to a registered icon and falls back gracefully. */
iconKey: string | null;
sortOrder: number;
isActive: boolean;
}
/** `OptionValueDto` — a concrete answer within an option group (e.g. شبانه‌روزی / Live-in). */
export interface ServiceOptionValue extends CatalogName {
id: number;
sortOrder: number;
isActive: boolean;
}
/**
* `OptionGroupDto` — a configurable dimension for a category (e.g. نوع شیفت / Shift type).
* `serviceCategoryId = null` ⇒ **cross-category** (applies to every category). `isRequired` groups
* must be answered (one value) before a variant can be created.
*/
export interface ServiceOptionGroup extends CatalogName {
id: number;
serviceCategoryId: number | null;
isRequired: boolean;
sortOrder: number;
isActive: boolean;
values: ServiceOptionValue[];
}
/** `VariantOptionDto` — one answered dimension on a variant (denormalised group + value labels). */
export interface VariantOption {
optionGroupId: number;
groupNameFa: string;
groupNameEn: string;
optionValueId: number;
valueNameFa: string;
valueNameEn: string;
}
/** `VariantDto` — a nurse's priced offering: category + chosen option values + own price + unit. */
export interface NurseServiceVariant {
id: number;
serviceCategoryId: number;
categoryNameFa: string;
categoryNameEn: string;
/** IRR Rials as a string of digits (money-and-types.md). Rendered only via the money util. */
price: string;
priceUnit: PriceUnit;
sessionCount: number | null;
displayName: string;
isActive: boolean;
options: VariantOption[];
}
/** One answered dimension in a create request — one value per group (mirrors the server UNIQUE). */
export interface VariantOptionSelection {
optionGroupId: number;
optionValueId: number;
}
/**
* `nurse_variants/create` body. `price` is the IRR digit-string (converted from Toman at the field
* boundary). Omit `displayName` to let the server auto-generate it from the category + value labels.
*/
export interface CreateVariantInput {
serviceCategoryId: number;
options: VariantOptionSelection[];
price: string;
priceUnit: PriceUnit;
sessionCount?: number | null;
displayName?: string | null;
}
/**
* `nurse_variants/update/{id}` body — edits price/unit/session/display only. The option-set is
* **immutable** on update (changing dimensions = create-new + deactivate-old). A blank `displayName`
* leaves the current one unchanged.
*/
export interface UpdateVariantInput {
price: string;
priceUnit: PriceUnit;
sessionCount?: number | null;
displayName?: string | null;
}
/**
* The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
*/
export interface CatalogApi {
/** Public reference data — active categories, ordered by `sortOrder`, paginated + cached. */
listCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
/** A category's applicable option groups (its own + every cross-category group), with values. Cached. */
getCategoryOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
/** The signed-in nurse's own offerings — active and inactive, active-first, paginated. */
listMyVariants(params?: PageParams): Promise<Paginated<NurseServiceVariant>>;
getVariant(id: number): Promise<NurseServiceVariant>;
createVariant(input: CreateVariantInput): Promise<NurseServiceVariant>;
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
/** Soft deactivate/reactivate — never a hard delete. */
setVariantActive(id: number, isActive: boolean): Promise<void>;
}
/**
* The applicable option groups for a category = its own groups **plus** every cross-category
* (`serviceCategoryId === null`) group. The server already returns exactly this set from
* `getCategoryOptionGroups`; this predicate documents the rule and is used by the mock.
*/
export function isGroupApplicable(group: ServiceOptionGroup, categoryId: number): boolean {
return group.serviceCategoryId === null || group.serviceCategoryId === categoryId;
}
/**
* A stable, order-independent signature of a variant's answered option-set, used for the
* duplicate-listing guard (same nurse + same category + identical option-set → 409). The server's
* uniqueness is the source of truth; this lets the mock reproduce the 409 and the builder pre-warn.
*/
export function optionSetSignature(categoryId: number, valueIds: number[]): string {
return `${categoryId}:${[...valueIds].sort((a, b) => a - b).join(',')}`;
}