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,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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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 1–5, `sortOrder` 0–4, `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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(',')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user