@stapel/categories-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +194 -0
- package/README.md +191 -0
- package/dist/api/categoriesApi.d.ts +121 -0
- package/dist/api/categoriesApi.d.ts.map +1 -0
- package/dist/api/categoriesApi.js +48 -0
- package/dist/api/categoriesApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +2321 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +92 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/catalog/labels.d.ts +103 -0
- package/dist/catalog/labels.d.ts.map +1 -0
- package/dist/catalog/labels.js +56 -0
- package/dist/catalog/labels.js.map +1 -0
- package/dist/catalog/pks.d.ts +25 -0
- package/dist/catalog/pks.d.ts.map +1 -0
- package/dist/catalog/pks.js +38 -0
- package/dist/catalog/pks.js.map +1 -0
- package/dist/catalog/sync.d.ts +103 -0
- package/dist/catalog/sync.d.ts.map +1 -0
- package/dist/catalog/sync.js +114 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/catalog/tree.d.ts +119 -0
- package/dist/catalog/tree.d.ts.map +1 -0
- package/dist/catalog/tree.js +166 -0
- package/dist/catalog/tree.js.map +1 -0
- package/dist/default/CatalogPage.d.ts +16 -0
- package/dist/default/CatalogPage.d.ts.map +1 -0
- package/dist/default/CatalogPage.js +13 -0
- package/dist/default/CatalogPage.js.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.js +35 -0
- package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
- package/dist/default/CategoryCarouselStrip.d.ts +22 -0
- package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
- package/dist/default/CategoryCarouselStrip.js +24 -0
- package/dist/default/CategoryCarouselStrip.js.map +1 -0
- package/dist/default/CategoryFeatureList.d.ts +23 -0
- package/dist/default/CategoryFeatureList.d.ts.map +1 -0
- package/dist/default/CategoryFeatureList.js +28 -0
- package/dist/default/CategoryFeatureList.js.map +1 -0
- package/dist/default/CategoryPage.d.ts +43 -0
- package/dist/default/CategoryPage.d.ts.map +1 -0
- package/dist/default/CategoryPage.js +21 -0
- package/dist/default/CategoryPage.js.map +1 -0
- package/dist/default/CategoryPickerField.d.ts +26 -0
- package/dist/default/CategoryPickerField.d.ts.map +1 -0
- package/dist/default/CategoryPickerField.js +40 -0
- package/dist/default/CategoryPickerField.js.map +1 -0
- package/dist/default/CategoryTreePane.d.ts +28 -0
- package/dist/default/CategoryTreePane.d.ts.map +1 -0
- package/dist/default/CategoryTreePane.js +25 -0
- package/dist/default/CategoryTreePane.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/index.d.ts +37 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +28 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +25 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +40 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/CategoriesProvider.d.ts +19 -0
- package/dist/headless/CategoriesProvider.d.ts.map +1 -0
- package/dist/headless/CategoriesProvider.js +15 -0
- package/dist/headless/CategoriesProvider.js.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.js +42 -0
- package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
- package/dist/headless/CategoryCarousel.d.ts +48 -0
- package/dist/headless/CategoryCarousel.d.ts.map +1 -0
- package/dist/headless/CategoryCarousel.js +34 -0
- package/dist/headless/CategoryCarousel.js.map +1 -0
- package/dist/headless/CategoryFeatures.d.ts +67 -0
- package/dist/headless/CategoryFeatures.d.ts.map +1 -0
- package/dist/headless/CategoryFeatures.js +46 -0
- package/dist/headless/CategoryFeatures.js.map +1 -0
- package/dist/headless/CategoryPicker.d.ts +69 -0
- package/dist/headless/CategoryPicker.d.ts.map +1 -0
- package/dist/headless/CategoryPicker.js +86 -0
- package/dist/headless/CategoryPicker.js.map +1 -0
- package/dist/headless/CategoryTree.d.ts +55 -0
- package/dist/headless/CategoryTree.d.ts.map +1 -0
- package/dist/headless/CategoryTree.js +51 -0
- package/dist/headless/CategoryTree.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +22 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +31 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +25 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +74 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +407 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +207 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +89 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +134 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +41 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +90 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/model/catalogStore.d.ts +23 -0
- package/dist/model/catalogStore.d.ts.map +1 -0
- package/dist/model/catalogStore.js +85 -0
- package/dist/model/catalogStore.js.map +1 -0
- package/dist/model/catalogSync.d.ts +49 -0
- package/dist/model/catalogSync.d.ts.map +1 -0
- package/dist/model/catalogSync.js +64 -0
- package/dist/model/catalogSync.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +124 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +147 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +30 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +16 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +31 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +31 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +37 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +125 -0
- package/manifest.json +918 -0
- package/nav-manifest.json +44 -0
- package/package.json +119 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/categoriesApi.ts +179 -0
- package/src/api/generated/schema.ts +2323 -0
- package/src/api/types.ts +99 -0
- package/src/catalog/labels.ts +129 -0
- package/src/catalog/pks.ts +35 -0
- package/src/catalog/sync.ts +169 -0
- package/src/catalog/tree.ts +242 -0
- package/src/default/CatalogPage.tsx +40 -0
- package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
- package/src/default/CategoryCarouselStrip.tsx +102 -0
- package/src/default/CategoryFeatureList.tsx +125 -0
- package/src/default/CategoryPage.tsx +114 -0
- package/src/default/CategoryPickerField.tsx +173 -0
- package/src/default/CategoryTreePane.tsx +134 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/index.ts +36 -0
- package/src/default/theme.tsx +67 -0
- package/src/default/types.ts +12 -0
- package/src/headless/CategoriesProvider.tsx +20 -0
- package/src/headless/CategoryBreadcrumbs.tsx +78 -0
- package/src/headless/CategoryCarousel.tsx +80 -0
- package/src/headless/CategoryFeatures.tsx +100 -0
- package/src/headless/CategoryPicker.tsx +173 -0
- package/src/headless/CategoryTree.tsx +115 -0
- package/src/i18n/errorsMap.ts +46 -0
- package/src/i18n/es.ts +92 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +238 -0
- package/src/i18n/generated/errors.json +495 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +156 -0
- package/src/i18n/ru.ts +110 -0
- package/src/index.ts +191 -0
- package/src/model/catalogStore.ts +102 -0
- package/src/model/catalogSync.ts +115 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +226 -0
- package/src/model/queryKeys.ts +50 -0
- package/src/model/runtime.ts +39 -0
- package/src/nav/manifest.ts +66 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CatalogPage>` — the catalogue root at `/c`: the carousel over the top-level
|
|
3
|
+
* tree.
|
|
4
|
+
*
|
|
5
|
+
* This is a single-pair screen, which is exactly why it has a nav entry while
|
|
6
|
+
* `/` does not. The storefront's landing composes THIS pair's carousel with
|
|
7
|
+
* search's newest-listings feed (spec §5.1), and a composed route belongs to
|
|
8
|
+
* the container that composes it — see `nav/manifest.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactElement } from "react";
|
|
11
|
+
import { Flex, Typography } from "antd";
|
|
12
|
+
import { useT } from "@stapel/core";
|
|
13
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
14
|
+
import { CategoryCarouselStrip } from "./CategoryCarouselStrip.js";
|
|
15
|
+
import { CategoryTreePane } from "./CategoryTreePane.js";
|
|
16
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
17
|
+
import type { ThemeModeProp } from "./types.js";
|
|
18
|
+
|
|
19
|
+
export interface CatalogPageProps extends ThemeModeProp {
|
|
20
|
+
readonly basePath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function CatalogPage(props: CatalogPageProps): ReactElement {
|
|
24
|
+
const t = useT();
|
|
25
|
+
const base = props.basePath ?? "/c";
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<CategoriesSkinTheme
|
|
29
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
30
|
+
>
|
|
31
|
+
<Flex vertical gap={16} data-testid="categories-catalog-page">
|
|
32
|
+
<Typography.Title level={3} style={{ margin: 0 }}>
|
|
33
|
+
{t(CATEGORIES_I18N_KEYS.catalogTitle)}
|
|
34
|
+
</Typography.Title>
|
|
35
|
+
<CategoryCarouselStrip basePath={base} />
|
|
36
|
+
<CategoryTreePane basePath={base} />
|
|
37
|
+
</Flex>
|
|
38
|
+
</CategoriesSkinTheme>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryBreadcrumbsBar>` — root → current, as antd breadcrumbs.
|
|
3
|
+
*
|
|
4
|
+
* Every caption goes through `renderCategoryLabel`, which is the one place
|
|
5
|
+
* this skin decides between "translate this" and "print this": a category's
|
|
6
|
+
* `name` is a translation KEY unless the row says `translatable: false`
|
|
7
|
+
* (`catalog/labels.ts`). A skin that called `t()` unconditionally would print
|
|
8
|
+
* the stored string for an untranslatable category twice-wrapped, and one that
|
|
9
|
+
* never called it would show `category.electronics` at a visitor.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
import { Breadcrumb, Skeleton, Typography } from "antd";
|
|
13
|
+
import { matchLoad, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
14
|
+
import { renderCategoryLabel } from "../catalog/labels.js";
|
|
15
|
+
import { CategoryBreadcrumbs } from "../headless/CategoryBreadcrumbs.js";
|
|
16
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
17
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
18
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
19
|
+
import type { ThemeModeProp } from "./types.js";
|
|
20
|
+
|
|
21
|
+
export interface CategoryBreadcrumbsBarProps extends ThemeModeProp {
|
|
22
|
+
readonly slug?: string;
|
|
23
|
+
readonly categoryId?: number | null;
|
|
24
|
+
/** Path prefix for a crumb's link. Default `/c`. */
|
|
25
|
+
readonly basePath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CategoryBreadcrumbsBar(
|
|
29
|
+
props: CategoryBreadcrumbsBarProps
|
|
30
|
+
): ReactElement {
|
|
31
|
+
const t = useT();
|
|
32
|
+
const describe = useDescribeFlowError();
|
|
33
|
+
const base = props.basePath ?? "/c";
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<CategoriesSkinTheme
|
|
37
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
38
|
+
>
|
|
39
|
+
<CategoryBreadcrumbs
|
|
40
|
+
{...(props.slug !== undefined ? { slug: props.slug } : {})}
|
|
41
|
+
{...(props.categoryId !== undefined
|
|
42
|
+
? { categoryId: props.categoryId }
|
|
43
|
+
: {})}
|
|
44
|
+
>
|
|
45
|
+
{(bag) =>
|
|
46
|
+
matchLoad(bag.state, {
|
|
47
|
+
loading: () => (
|
|
48
|
+
<Skeleton.Input
|
|
49
|
+
active
|
|
50
|
+
size="small"
|
|
51
|
+
data-testid="categories-breadcrumbs-loading"
|
|
52
|
+
/>
|
|
53
|
+
),
|
|
54
|
+
failed: (error) => (
|
|
55
|
+
<ErrorAlert
|
|
56
|
+
testId="categories-breadcrumbs-failed"
|
|
57
|
+
error={{
|
|
58
|
+
...describe(toFlowError(error)),
|
|
59
|
+
message: t(CATEGORIES_I18N_KEYS.catalogLoadFailed),
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
),
|
|
63
|
+
ready: (crumbs) =>
|
|
64
|
+
bag.unknownSlug ? (
|
|
65
|
+
<Typography.Text
|
|
66
|
+
type="secondary"
|
|
67
|
+
data-testid="categories-breadcrumbs-unknown"
|
|
68
|
+
>
|
|
69
|
+
{t(CATEGORIES_I18N_KEYS.categoryUnknownSlug)}
|
|
70
|
+
</Typography.Text>
|
|
71
|
+
) : (
|
|
72
|
+
<Breadcrumb
|
|
73
|
+
data-testid="categories-breadcrumbs"
|
|
74
|
+
items={[
|
|
75
|
+
{
|
|
76
|
+
title: t(CATEGORIES_I18N_KEYS.breadcrumbsRoot),
|
|
77
|
+
href: base,
|
|
78
|
+
},
|
|
79
|
+
...crumbs.map((crumb) => ({
|
|
80
|
+
title: renderCategoryLabel(crumb.label, t),
|
|
81
|
+
...(crumb.isCurrent
|
|
82
|
+
? {}
|
|
83
|
+
: { href: `${base}/${crumb.node.category.slug}` }),
|
|
84
|
+
})),
|
|
85
|
+
]}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
</CategoryBreadcrumbs>
|
|
91
|
+
</CategoriesSkinTheme>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryCarouselStrip>` — the landing page's row of category tiles.
|
|
3
|
+
*
|
|
4
|
+
* The icon references (`carousel_icon` / `catalog_icon`) are OPAQUE STRINGS
|
|
5
|
+
* that the backend deliberately does not resolve ("Decoupled from stapel-cdn:
|
|
6
|
+
* an opaque string, resolved by the host if at all"). This skin therefore
|
|
7
|
+
* renders no `<img>` and builds no URL: it hands the reference to the host
|
|
8
|
+
* through `renderIcon`, and draws nothing when the host does not supply one. A
|
|
9
|
+
* guessed CDN path would be a broken image on every deployment that guessed
|
|
10
|
+
* differently, and a broken image is worse than no image.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactElement, ReactNode } from "react";
|
|
13
|
+
import { Card, Empty, Flex, Skeleton, Typography } from "antd";
|
|
14
|
+
import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
15
|
+
import { renderCategoryLabel } from "../catalog/labels.js";
|
|
16
|
+
import { CategoryCarousel } from "../headless/CategoryCarousel.js";
|
|
17
|
+
import type { CarouselEntry } from "../headless/CategoryCarousel.js";
|
|
18
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
19
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
20
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
21
|
+
import type { ThemeModeProp } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export interface CategoryCarouselStripProps extends ThemeModeProp {
|
|
24
|
+
readonly basePath?: string;
|
|
25
|
+
/** Turn an opaque icon reference into something renderable. Absent means no
|
|
26
|
+
* icon is drawn — see this file's header. */
|
|
27
|
+
readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CategoryCarouselStrip(
|
|
31
|
+
props: CategoryCarouselStripProps
|
|
32
|
+
): ReactElement {
|
|
33
|
+
const t = useT();
|
|
34
|
+
const describe = useDescribeFlowError();
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<CategoriesSkinTheme
|
|
38
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
39
|
+
>
|
|
40
|
+
<CategoryCarousel
|
|
41
|
+
{...(props.basePath !== undefined ? { basePath: props.basePath } : {})}
|
|
42
|
+
>
|
|
43
|
+
{(bag) => (
|
|
44
|
+
<Flex vertical gap={8} data-testid="categories-carousel">
|
|
45
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
46
|
+
{t(CATEGORIES_I18N_KEYS.carouselTitle)}
|
|
47
|
+
</Typography.Title>
|
|
48
|
+
|
|
49
|
+
{matchList(bag.state, {
|
|
50
|
+
loading: () => (
|
|
51
|
+
<Flex gap={8}>
|
|
52
|
+
<Skeleton.Button
|
|
53
|
+
active
|
|
54
|
+
data-testid="categories-carousel-loading"
|
|
55
|
+
/>
|
|
56
|
+
<Skeleton.Button active />
|
|
57
|
+
<Skeleton.Button active />
|
|
58
|
+
</Flex>
|
|
59
|
+
),
|
|
60
|
+
failed: (error) => (
|
|
61
|
+
<ErrorAlert
|
|
62
|
+
testId="categories-carousel-failed"
|
|
63
|
+
error={{
|
|
64
|
+
...describe(toFlowError(error)),
|
|
65
|
+
message: t(CATEGORIES_I18N_KEYS.carouselLoadFailed),
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
),
|
|
69
|
+
empty: () => (
|
|
70
|
+
<Empty
|
|
71
|
+
data-testid="categories-carousel-empty"
|
|
72
|
+
description={t(CATEGORIES_I18N_KEYS.carouselEmpty)}
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
ready: (entries) => (
|
|
76
|
+
<Flex gap={8} wrap data-testid="categories-carousel-list">
|
|
77
|
+
{entries.map((entry) => (
|
|
78
|
+
<Card
|
|
79
|
+
key={entry.category.id}
|
|
80
|
+
size="small"
|
|
81
|
+
hoverable
|
|
82
|
+
data-category-slug={entry.category.slug}
|
|
83
|
+
>
|
|
84
|
+
<Typography.Link href={entry.href}>
|
|
85
|
+
<Flex align="center" gap={8}>
|
|
86
|
+
{entry.icon !== null && props.renderIcon !== undefined
|
|
87
|
+
? props.renderIcon(entry.icon, entry)
|
|
88
|
+
: null}
|
|
89
|
+
<span>{renderCategoryLabel(entry.label, t)}</span>
|
|
90
|
+
</Flex>
|
|
91
|
+
</Typography.Link>
|
|
92
|
+
</Card>
|
|
93
|
+
))}
|
|
94
|
+
</Flex>
|
|
95
|
+
),
|
|
96
|
+
})}
|
|
97
|
+
</Flex>
|
|
98
|
+
)}
|
|
99
|
+
</CategoryCarousel>
|
|
100
|
+
</CategoriesSkinTheme>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryFeatureList>` — a category's feature SCHEMA, rendered.
|
|
3
|
+
*
|
|
4
|
+
* The display integration with `@stapel/attributes-react` in the direction
|
|
5
|
+
* this pair owns: categories serves the definitions, attributes knows what a
|
|
6
|
+
* definition MEANS. So the type badge is not a string this file invents —
|
|
7
|
+
* `featureType` is attributes-react's single reader of `config.type`, and
|
|
8
|
+
* `BUILTIN_VALUE_EDITOR_TYPES` is its own statement of which types a build can
|
|
9
|
+
* actually draw. A type outside that set is marked here, on the schema, rather
|
|
10
|
+
* than discovered when somebody opens the compose form and finds a missing
|
|
11
|
+
* field.
|
|
12
|
+
*
|
|
13
|
+
* The other direction — values, not definitions — belongs to attributes-react
|
|
14
|
+
* outright: `formatFeatureValue`, `<FeatureBadges>`, `<FeatureValueList>`. A
|
|
15
|
+
* listing card imports those directly and this pair is not in the path.
|
|
16
|
+
*/
|
|
17
|
+
import type { ReactElement } from "react";
|
|
18
|
+
import { Empty, Flex, List, Spin, Tag, Typography } from "antd";
|
|
19
|
+
import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
20
|
+
import { BUILTIN_VALUE_EDITOR_TYPES } from "@stapel/attributes-react/default";
|
|
21
|
+
import { renderCategoryLabel } from "../catalog/labels.js";
|
|
22
|
+
import { CategoryFeatures } from "../headless/CategoryFeatures.js";
|
|
23
|
+
import type { CategoryFeatureEntry } from "../headless/CategoryFeatures.js";
|
|
24
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
25
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
26
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
27
|
+
import type { ThemeModeProp } from "./types.js";
|
|
28
|
+
|
|
29
|
+
export interface CategoryFeatureListProps extends ThemeModeProp {
|
|
30
|
+
readonly categoryId: number | null | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CategoryFeatureList(
|
|
34
|
+
props: CategoryFeatureListProps
|
|
35
|
+
): ReactElement {
|
|
36
|
+
const t = useT();
|
|
37
|
+
const describe = useDescribeFlowError();
|
|
38
|
+
const drawable = new Set<string>(BUILTIN_VALUE_EDITOR_TYPES);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<CategoriesSkinTheme
|
|
42
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
43
|
+
>
|
|
44
|
+
<CategoryFeatures categoryId={props.categoryId}>
|
|
45
|
+
{(bag) => (
|
|
46
|
+
<Flex vertical gap={8} data-testid="categories-features">
|
|
47
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
48
|
+
{t(CATEGORIES_I18N_KEYS.featuresTitle)}
|
|
49
|
+
</Typography.Title>
|
|
50
|
+
|
|
51
|
+
{matchList(bag.state, {
|
|
52
|
+
loading: () => (
|
|
53
|
+
<Flex justify="center" style={{ padding: 16 }}>
|
|
54
|
+
<Spin data-testid="categories-features-loading" />
|
|
55
|
+
</Flex>
|
|
56
|
+
),
|
|
57
|
+
failed: (error) => (
|
|
58
|
+
<ErrorAlert
|
|
59
|
+
testId="categories-features-failed"
|
|
60
|
+
error={{
|
|
61
|
+
...describe(toFlowError(error)),
|
|
62
|
+
message: t(CATEGORIES_I18N_KEYS.featuresLoadFailed),
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
),
|
|
66
|
+
empty: () => (
|
|
67
|
+
<Empty
|
|
68
|
+
data-testid="categories-features-empty"
|
|
69
|
+
description={t(CATEGORIES_I18N_KEYS.featuresEmpty)}
|
|
70
|
+
/>
|
|
71
|
+
),
|
|
72
|
+
ready: (entries) => (
|
|
73
|
+
<List<CategoryFeatureEntry>
|
|
74
|
+
data-testid="categories-features-list"
|
|
75
|
+
size="small"
|
|
76
|
+
dataSource={[...entries]}
|
|
77
|
+
renderItem={(entry) => (
|
|
78
|
+
<List.Item
|
|
79
|
+
key={entry.feature.slug}
|
|
80
|
+
data-feature-slug={entry.feature.slug}
|
|
81
|
+
>
|
|
82
|
+
<Flex
|
|
83
|
+
justify="space-between"
|
|
84
|
+
align="center"
|
|
85
|
+
gap={8}
|
|
86
|
+
style={{ width: "100%" }}
|
|
87
|
+
>
|
|
88
|
+
<Typography.Text>
|
|
89
|
+
{renderCategoryLabel(entry.label, t)}
|
|
90
|
+
</Typography.Text>
|
|
91
|
+
<Flex gap={4} align="center">
|
|
92
|
+
{entry.mandatory ? (
|
|
93
|
+
<Tag color="red">
|
|
94
|
+
{t(CATEGORIES_I18N_KEYS.featuresMandatory)}
|
|
95
|
+
</Tag>
|
|
96
|
+
) : null}
|
|
97
|
+
{entry.type === undefined ? (
|
|
98
|
+
<Tag color="warning" data-feature-untyped>
|
|
99
|
+
{t(CATEGORIES_I18N_KEYS.featuresUntyped)}
|
|
100
|
+
</Tag>
|
|
101
|
+
) : (
|
|
102
|
+
<Tag
|
|
103
|
+
{...(drawable.has(entry.type)
|
|
104
|
+
? {}
|
|
105
|
+
: { color: "warning" })}
|
|
106
|
+
data-feature-type={entry.type}
|
|
107
|
+
>
|
|
108
|
+
{t(CATEGORIES_I18N_KEYS.featuresType, {
|
|
109
|
+
type: entry.type,
|
|
110
|
+
})}
|
|
111
|
+
</Tag>
|
|
112
|
+
)}
|
|
113
|
+
</Flex>
|
|
114
|
+
</Flex>
|
|
115
|
+
</List.Item>
|
|
116
|
+
)}
|
|
117
|
+
/>
|
|
118
|
+
),
|
|
119
|
+
})}
|
|
120
|
+
</Flex>
|
|
121
|
+
)}
|
|
122
|
+
</CategoryFeatures>
|
|
123
|
+
</CategoriesSkinTheme>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryPage>` — the `/c/:slug` screen: breadcrumbs, sub-categories, and a
|
|
3
|
+
* SLOT where the listings go.
|
|
4
|
+
*
|
|
5
|
+
* The slot is the whole design. Spec §5.1 makes this route "categories +
|
|
6
|
+
* search": the sub-category menu comes from here, the results with facets come
|
|
7
|
+
* from `@stapel/search-react`. A pair may not import another L2 pair
|
|
8
|
+
* (dependency direction is strictly downward), and a screen that rendered only
|
|
9
|
+
* half of itself would be useless — so the half this pair does not own is a
|
|
10
|
+
* render prop, handed the resolved category. The container writes one line:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <CategoryPage
|
|
14
|
+
* slug={slug}
|
|
15
|
+
* renderListings={(category) => (
|
|
16
|
+
* <SearchPage adapter={useRouterSearchParams()} defaultType="listing"
|
|
17
|
+
* fixedCategory={category.id} />
|
|
18
|
+
* )}
|
|
19
|
+
* />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `slug` resolution happens against the synced tree because the SERVER cannot
|
|
23
|
+
* do it (`nav/manifest.ts`, `catalog/tree.ts`). Until the catalogue has
|
|
24
|
+
* loaded, an unknown slug is not yet unknown — the "no category here" line is
|
|
25
|
+
* shown only once the sync succeeded, so a slow network never renders a 404
|
|
26
|
+
* for a page that exists.
|
|
27
|
+
*/
|
|
28
|
+
import type { ReactElement, ReactNode } from "react";
|
|
29
|
+
import { Empty, Flex, Spin, Typography } from "antd";
|
|
30
|
+
import { toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
31
|
+
import type { Category } from "../api/types.js";
|
|
32
|
+
import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js";
|
|
33
|
+
import { CategoryTree } from "../headless/CategoryTree.js";
|
|
34
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
35
|
+
import { CategoryBreadcrumbsBar } from "./CategoryBreadcrumbsBar.js";
|
|
36
|
+
import { CategoryFeatureList } from "./CategoryFeatureList.js";
|
|
37
|
+
import { CategoryTreePane } from "./CategoryTreePane.js";
|
|
38
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
39
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
40
|
+
import type { ThemeModeProp } from "./types.js";
|
|
41
|
+
|
|
42
|
+
export interface CategoryPageProps extends ThemeModeProp {
|
|
43
|
+
/** The `:slug` segment of `/c/:slug`. */
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
readonly basePath?: string;
|
|
46
|
+
/** The listings half of the screen — supplied by the container, because it
|
|
47
|
+
* belongs to another pair. */
|
|
48
|
+
readonly renderListings?: (category: Category) => ReactNode;
|
|
49
|
+
/** Show the category's feature schema. Off by default: on a storefront the
|
|
50
|
+
* schema is the facet panel's input, not a visitor-facing table. */
|
|
51
|
+
readonly showFeatures?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function CategoryPage(props: CategoryPageProps): ReactElement {
|
|
55
|
+
const t = useT();
|
|
56
|
+
const describe = useDescribeFlowError();
|
|
57
|
+
const base = props.basePath ?? "/c";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<CategoriesSkinTheme
|
|
61
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
62
|
+
>
|
|
63
|
+
<CategoryTree slug={props.slug}>
|
|
64
|
+
{(bag) => (
|
|
65
|
+
<Flex vertical gap={16} data-testid="categories-category-page">
|
|
66
|
+
<CategoryBreadcrumbsBar slug={props.slug} basePath={base} />
|
|
67
|
+
|
|
68
|
+
{bag.catalog.status === "loading" ? (
|
|
69
|
+
<Flex justify="center" style={{ padding: 16 }}>
|
|
70
|
+
<Spin data-testid="categories-category-loading" />
|
|
71
|
+
</Flex>
|
|
72
|
+
) : bag.catalog.status === "failed" ? (
|
|
73
|
+
<ErrorAlert
|
|
74
|
+
testId="categories-category-failed"
|
|
75
|
+
error={{
|
|
76
|
+
...describe(toFlowError(bag.catalog.error)),
|
|
77
|
+
message: t(CATEGORIES_I18N_KEYS.catalogLoadFailed),
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
) : bag.current === null ? (
|
|
81
|
+
<Empty
|
|
82
|
+
data-testid="categories-category-unknown"
|
|
83
|
+
description={t(CATEGORIES_I18N_KEYS.categoryUnknownSlug)}
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<Typography.Title level={3} style={{ margin: 0 }}>
|
|
88
|
+
{renderCategoryLabel(
|
|
89
|
+
categoryLabel(bag.current.category),
|
|
90
|
+
t
|
|
91
|
+
)}
|
|
92
|
+
</Typography.Title>
|
|
93
|
+
|
|
94
|
+
{bag.current.children.length > 0 ? (
|
|
95
|
+
<CategoryTreePane
|
|
96
|
+
slug={props.slug}
|
|
97
|
+
basePath={base}
|
|
98
|
+
titleKey={CATEGORIES_I18N_KEYS.categorySubcategories}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
|
|
102
|
+
{props.showFeatures === true ? (
|
|
103
|
+
<CategoryFeatureList categoryId={bag.current.id} />
|
|
104
|
+
) : null}
|
|
105
|
+
|
|
106
|
+
{props.renderListings?.(bag.current.category)}
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
</Flex>
|
|
110
|
+
)}
|
|
111
|
+
</CategoryTree>
|
|
112
|
+
</CategoriesSkinTheme>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryPickerField>` — the compose form's category chooser.
|
|
3
|
+
*
|
|
4
|
+
* Drill down or search; either way the options come out of the tree that is
|
|
5
|
+
* already in memory, so typing does not issue a request per keystroke and the
|
|
6
|
+
* whole control works offline once the catalogue has synced.
|
|
7
|
+
*
|
|
8
|
+
* The blocked state carries its reason, always. `ActionAvailability`'s rule is
|
|
9
|
+
* that a disabled control must say why, and this control has two different
|
|
10
|
+
* whys — nothing chosen yet, versus a chosen category that still has
|
|
11
|
+
* sub-categories. The second one matters more than it looks: filing a listing
|
|
12
|
+
* under "Electronics" instead of "Electronics › Phones" inherits a different
|
|
13
|
+
* feature set, so the form then asks the wrong questions and the server
|
|
14
|
+
* refuses a field the person never saw.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactElement } from "react";
|
|
17
|
+
import { Alert, Breadcrumb, Button, Empty, Flex, Input, List, Spin } from "antd";
|
|
18
|
+
import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
19
|
+
import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js";
|
|
20
|
+
import type { CategoryNode } from "../catalog/tree.js";
|
|
21
|
+
import { CategoryPicker } from "../headless/CategoryPicker.js";
|
|
22
|
+
import type { CategoryOption } from "../headless/CategoryPicker.js";
|
|
23
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
24
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
25
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
26
|
+
import type { ThemeModeProp } from "./types.js";
|
|
27
|
+
|
|
28
|
+
const BLOCKED_KEY = {
|
|
29
|
+
nothing_selected: CATEGORIES_I18N_KEYS.pickerBlockedNothingSelected,
|
|
30
|
+
not_a_leaf: CATEGORIES_I18N_KEYS.pickerBlockedNotALeaf,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export interface CategoryPickerFieldProps extends ThemeModeProp {
|
|
34
|
+
readonly value?: number | null;
|
|
35
|
+
readonly onChange?: (id: number | null, node: CategoryNode | null) => void;
|
|
36
|
+
/** Only leaf categories are valid. Default `true`. */
|
|
37
|
+
readonly leavesOnly?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CategoryPickerField(
|
|
41
|
+
props: CategoryPickerFieldProps
|
|
42
|
+
): ReactElement {
|
|
43
|
+
const t = useT();
|
|
44
|
+
const describe = useDescribeFlowError();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<CategoriesSkinTheme
|
|
48
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
49
|
+
>
|
|
50
|
+
<CategoryPicker
|
|
51
|
+
{...(props.value !== undefined ? { value: props.value } : {})}
|
|
52
|
+
{...(props.onChange !== undefined ? { onChange: props.onChange } : {})}
|
|
53
|
+
{...(props.leavesOnly !== undefined
|
|
54
|
+
? { leavesOnly: props.leavesOnly }
|
|
55
|
+
: {})}
|
|
56
|
+
translate={t}
|
|
57
|
+
>
|
|
58
|
+
{(bag) => (
|
|
59
|
+
<Flex vertical gap={8} data-testid="categories-picker">
|
|
60
|
+
<Input
|
|
61
|
+
allowClear
|
|
62
|
+
value={bag.query}
|
|
63
|
+
placeholder={t(CATEGORIES_I18N_KEYS.pickerSearch)}
|
|
64
|
+
aria-label={t(CATEGORIES_I18N_KEYS.pickerSearch)}
|
|
65
|
+
data-testid="categories-picker-search"
|
|
66
|
+
onChange={(event) => {
|
|
67
|
+
bag.setQuery(event.target.value);
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
{bag.query === "" && bag.path.length > 0 ? (
|
|
72
|
+
<Flex align="center" gap={8}>
|
|
73
|
+
<Button
|
|
74
|
+
size="small"
|
|
75
|
+
data-testid="categories-picker-up"
|
|
76
|
+
data-analytics="none"
|
|
77
|
+
data-analytics-reason="walking the local tree is a read; nothing leaves the browser"
|
|
78
|
+
onClick={bag.up}
|
|
79
|
+
>
|
|
80
|
+
{t(CATEGORIES_I18N_KEYS.pickerUp)}
|
|
81
|
+
</Button>
|
|
82
|
+
<Breadcrumb
|
|
83
|
+
items={bag.path.map((node) => ({
|
|
84
|
+
title: renderCategoryLabel(
|
|
85
|
+
categoryLabel(node.category),
|
|
86
|
+
t
|
|
87
|
+
),
|
|
88
|
+
}))}
|
|
89
|
+
/>
|
|
90
|
+
</Flex>
|
|
91
|
+
) : null}
|
|
92
|
+
|
|
93
|
+
{matchList(bag.state, {
|
|
94
|
+
loading: () => (
|
|
95
|
+
<Flex justify="center" style={{ padding: 16 }}>
|
|
96
|
+
<Spin data-testid="categories-picker-loading" />
|
|
97
|
+
</Flex>
|
|
98
|
+
),
|
|
99
|
+
failed: (error) => (
|
|
100
|
+
<ErrorAlert
|
|
101
|
+
testId="categories-picker-failed"
|
|
102
|
+
error={{
|
|
103
|
+
...describe(toFlowError(error)),
|
|
104
|
+
message: t(CATEGORIES_I18N_KEYS.pickerLoadFailed),
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
),
|
|
108
|
+
empty: () => (
|
|
109
|
+
<Empty
|
|
110
|
+
data-testid="categories-picker-empty"
|
|
111
|
+
description={t(
|
|
112
|
+
bag.query === ""
|
|
113
|
+
? CATEGORIES_I18N_KEYS.categoryNoSubcategories
|
|
114
|
+
: CATEGORIES_I18N_KEYS.pickerNoMatches
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
),
|
|
118
|
+
ready: (options) => (
|
|
119
|
+
<List<CategoryOption>
|
|
120
|
+
data-testid="categories-picker-list"
|
|
121
|
+
size="small"
|
|
122
|
+
dataSource={[...options]}
|
|
123
|
+
renderItem={(option) => (
|
|
124
|
+
<List.Item
|
|
125
|
+
key={option.node.id}
|
|
126
|
+
data-category-id={option.node.id}
|
|
127
|
+
>
|
|
128
|
+
<Button
|
|
129
|
+
type="link"
|
|
130
|
+
data-testid={`categories-picker-option-${String(option.node.id)}`}
|
|
131
|
+
data-analytics="none"
|
|
132
|
+
data-analytics-reason="drilling into the local tree is a read; the host tracks the SUBMIT that consumes the chosen category"
|
|
133
|
+
onClick={() => {
|
|
134
|
+
bag.open(option.node);
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{renderCategoryLabel(option.label, t)}
|
|
138
|
+
</Button>
|
|
139
|
+
</List.Item>
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
),
|
|
143
|
+
})}
|
|
144
|
+
|
|
145
|
+
{bag.submitBlockedReason !== null ? (
|
|
146
|
+
<Alert
|
|
147
|
+
type="info"
|
|
148
|
+
showIcon
|
|
149
|
+
data-testid="categories-picker-blocked"
|
|
150
|
+
message={t(BLOCKED_KEY[bag.submitBlockedReason])}
|
|
151
|
+
/>
|
|
152
|
+
) : (
|
|
153
|
+
<Alert
|
|
154
|
+
type="success"
|
|
155
|
+
showIcon
|
|
156
|
+
data-testid="categories-picker-selected"
|
|
157
|
+
message={t(CATEGORIES_I18N_KEYS.pickerSelected, {
|
|
158
|
+
category:
|
|
159
|
+
bag.selected === null
|
|
160
|
+
? ""
|
|
161
|
+
: renderCategoryLabel(
|
|
162
|
+
categoryLabel(bag.selected.category),
|
|
163
|
+
t
|
|
164
|
+
),
|
|
165
|
+
})}
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
168
|
+
</Flex>
|
|
169
|
+
)}
|
|
170
|
+
</CategoryPicker>
|
|
171
|
+
</CategoriesSkinTheme>
|
|
172
|
+
);
|
|
173
|
+
}
|