@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.
Files changed (205) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +194 -0
  3. package/README.md +191 -0
  4. package/dist/api/categoriesApi.d.ts +121 -0
  5. package/dist/api/categoriesApi.d.ts.map +1 -0
  6. package/dist/api/categoriesApi.js +48 -0
  7. package/dist/api/categoriesApi.js.map +1 -0
  8. package/dist/api/generated/schema.d.ts +2321 -0
  9. package/dist/api/generated/schema.d.ts.map +1 -0
  10. package/dist/api/generated/schema.js +2 -0
  11. package/dist/api/generated/schema.js.map +1 -0
  12. package/dist/api/types.d.ts +92 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/catalog/labels.d.ts +103 -0
  17. package/dist/catalog/labels.d.ts.map +1 -0
  18. package/dist/catalog/labels.js +56 -0
  19. package/dist/catalog/labels.js.map +1 -0
  20. package/dist/catalog/pks.d.ts +25 -0
  21. package/dist/catalog/pks.d.ts.map +1 -0
  22. package/dist/catalog/pks.js +38 -0
  23. package/dist/catalog/pks.js.map +1 -0
  24. package/dist/catalog/sync.d.ts +103 -0
  25. package/dist/catalog/sync.d.ts.map +1 -0
  26. package/dist/catalog/sync.js +114 -0
  27. package/dist/catalog/sync.js.map +1 -0
  28. package/dist/catalog/tree.d.ts +119 -0
  29. package/dist/catalog/tree.d.ts.map +1 -0
  30. package/dist/catalog/tree.js +166 -0
  31. package/dist/catalog/tree.js.map +1 -0
  32. package/dist/default/CatalogPage.d.ts +16 -0
  33. package/dist/default/CatalogPage.d.ts.map +1 -0
  34. package/dist/default/CatalogPage.js +13 -0
  35. package/dist/default/CatalogPage.js.map +1 -0
  36. package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
  37. package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
  38. package/dist/default/CategoryBreadcrumbsBar.js +35 -0
  39. package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
  40. package/dist/default/CategoryCarouselStrip.d.ts +22 -0
  41. package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
  42. package/dist/default/CategoryCarouselStrip.js +24 -0
  43. package/dist/default/CategoryCarouselStrip.js.map +1 -0
  44. package/dist/default/CategoryFeatureList.d.ts +23 -0
  45. package/dist/default/CategoryFeatureList.d.ts.map +1 -0
  46. package/dist/default/CategoryFeatureList.js +28 -0
  47. package/dist/default/CategoryFeatureList.js.map +1 -0
  48. package/dist/default/CategoryPage.d.ts +43 -0
  49. package/dist/default/CategoryPage.d.ts.map +1 -0
  50. package/dist/default/CategoryPage.js +21 -0
  51. package/dist/default/CategoryPage.js.map +1 -0
  52. package/dist/default/CategoryPickerField.d.ts +26 -0
  53. package/dist/default/CategoryPickerField.d.ts.map +1 -0
  54. package/dist/default/CategoryPickerField.js +40 -0
  55. package/dist/default/CategoryPickerField.js.map +1 -0
  56. package/dist/default/CategoryTreePane.d.ts +28 -0
  57. package/dist/default/CategoryTreePane.d.ts.map +1 -0
  58. package/dist/default/CategoryTreePane.js +25 -0
  59. package/dist/default/CategoryTreePane.js.map +1 -0
  60. package/dist/default/ErrorAlert.d.ts +9 -0
  61. package/dist/default/ErrorAlert.d.ts.map +1 -0
  62. package/dist/default/ErrorAlert.js +27 -0
  63. package/dist/default/ErrorAlert.js.map +1 -0
  64. package/dist/default/index.d.ts +37 -0
  65. package/dist/default/index.d.ts.map +1 -0
  66. package/dist/default/index.js +28 -0
  67. package/dist/default/index.js.map +1 -0
  68. package/dist/default/theme.d.ts +25 -0
  69. package/dist/default/theme.d.ts.map +1 -0
  70. package/dist/default/theme.js +40 -0
  71. package/dist/default/theme.js.map +1 -0
  72. package/dist/default/types.d.ts +12 -0
  73. package/dist/default/types.d.ts.map +1 -0
  74. package/dist/default/types.js +2 -0
  75. package/dist/default/types.js.map +1 -0
  76. package/dist/headless/CategoriesProvider.d.ts +19 -0
  77. package/dist/headless/CategoriesProvider.d.ts.map +1 -0
  78. package/dist/headless/CategoriesProvider.js +15 -0
  79. package/dist/headless/CategoriesProvider.js.map +1 -0
  80. package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
  81. package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
  82. package/dist/headless/CategoryBreadcrumbs.js +42 -0
  83. package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
  84. package/dist/headless/CategoryCarousel.d.ts +48 -0
  85. package/dist/headless/CategoryCarousel.d.ts.map +1 -0
  86. package/dist/headless/CategoryCarousel.js +34 -0
  87. package/dist/headless/CategoryCarousel.js.map +1 -0
  88. package/dist/headless/CategoryFeatures.d.ts +67 -0
  89. package/dist/headless/CategoryFeatures.d.ts.map +1 -0
  90. package/dist/headless/CategoryFeatures.js +46 -0
  91. package/dist/headless/CategoryFeatures.js.map +1 -0
  92. package/dist/headless/CategoryPicker.d.ts +69 -0
  93. package/dist/headless/CategoryPicker.d.ts.map +1 -0
  94. package/dist/headless/CategoryPicker.js +86 -0
  95. package/dist/headless/CategoryPicker.js.map +1 -0
  96. package/dist/headless/CategoryTree.d.ts +55 -0
  97. package/dist/headless/CategoryTree.d.ts.map +1 -0
  98. package/dist/headless/CategoryTree.js +51 -0
  99. package/dist/headless/CategoryTree.js.map +1 -0
  100. package/dist/i18n/errorsMap.d.ts +22 -0
  101. package/dist/i18n/errorsMap.d.ts.map +1 -0
  102. package/dist/i18n/errorsMap.js +31 -0
  103. package/dist/i18n/errorsMap.js.map +1 -0
  104. package/dist/i18n/es.d.ts +25 -0
  105. package/dist/i18n/es.d.ts.map +1 -0
  106. package/dist/i18n/es.js +74 -0
  107. package/dist/i18n/es.js.map +1 -0
  108. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  109. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.es.gen.js +58 -0
  111. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.gen.d.ts +407 -0
  113. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.gen.js +207 -0
  115. package/dist/i18n/generated/errors.gen.js.map +1 -0
  116. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  117. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  118. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  119. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  120. package/dist/i18n/keys.d.ts +89 -0
  121. package/dist/i18n/keys.d.ts.map +1 -0
  122. package/dist/i18n/keys.js +134 -0
  123. package/dist/i18n/keys.js.map +1 -0
  124. package/dist/i18n/ru.d.ts +41 -0
  125. package/dist/i18n/ru.d.ts.map +1 -0
  126. package/dist/i18n/ru.js +90 -0
  127. package/dist/i18n/ru.js.map +1 -0
  128. package/dist/index.d.ts +88 -0
  129. package/dist/index.d.ts.map +1 -0
  130. package/dist/index.js +78 -0
  131. package/dist/index.js.map +1 -0
  132. package/dist/model/catalogStore.d.ts +23 -0
  133. package/dist/model/catalogStore.d.ts.map +1 -0
  134. package/dist/model/catalogStore.js +85 -0
  135. package/dist/model/catalogStore.js.map +1 -0
  136. package/dist/model/catalogSync.d.ts +49 -0
  137. package/dist/model/catalogSync.d.ts.map +1 -0
  138. package/dist/model/catalogSync.js +64 -0
  139. package/dist/model/catalogSync.js.map +1 -0
  140. package/dist/model/context.d.ts +11 -0
  141. package/dist/model/context.d.ts.map +1 -0
  142. package/dist/model/context.js +16 -0
  143. package/dist/model/context.js.map +1 -0
  144. package/dist/model/queries.d.ts +124 -0
  145. package/dist/model/queries.d.ts.map +1 -0
  146. package/dist/model/queries.js +147 -0
  147. package/dist/model/queries.js.map +1 -0
  148. package/dist/model/queryKeys.d.ts +30 -0
  149. package/dist/model/queryKeys.d.ts.map +1 -0
  150. package/dist/model/queryKeys.js +16 -0
  151. package/dist/model/queryKeys.js.map +1 -0
  152. package/dist/model/runtime.d.ts +31 -0
  153. package/dist/model/runtime.d.ts.map +1 -0
  154. package/dist/model/runtime.js +6 -0
  155. package/dist/model/runtime.js.map +1 -0
  156. package/dist/nav/manifest.d.ts +31 -0
  157. package/dist/nav/manifest.d.ts.map +1 -0
  158. package/dist/nav/manifest.js +37 -0
  159. package/dist/nav/manifest.js.map +1 -0
  160. package/llms.txt +125 -0
  161. package/manifest.json +918 -0
  162. package/nav-manifest.json +44 -0
  163. package/package.json +119 -0
  164. package/src/analytics/generated/events.json +7 -0
  165. package/src/api/categoriesApi.ts +179 -0
  166. package/src/api/generated/schema.ts +2323 -0
  167. package/src/api/types.ts +99 -0
  168. package/src/catalog/labels.ts +129 -0
  169. package/src/catalog/pks.ts +35 -0
  170. package/src/catalog/sync.ts +169 -0
  171. package/src/catalog/tree.ts +242 -0
  172. package/src/default/CatalogPage.tsx +40 -0
  173. package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
  174. package/src/default/CategoryCarouselStrip.tsx +102 -0
  175. package/src/default/CategoryFeatureList.tsx +125 -0
  176. package/src/default/CategoryPage.tsx +114 -0
  177. package/src/default/CategoryPickerField.tsx +173 -0
  178. package/src/default/CategoryTreePane.tsx +134 -0
  179. package/src/default/ErrorAlert.tsx +46 -0
  180. package/src/default/index.ts +36 -0
  181. package/src/default/theme.tsx +67 -0
  182. package/src/default/types.ts +12 -0
  183. package/src/headless/CategoriesProvider.tsx +20 -0
  184. package/src/headless/CategoryBreadcrumbs.tsx +78 -0
  185. package/src/headless/CategoryCarousel.tsx +80 -0
  186. package/src/headless/CategoryFeatures.tsx +100 -0
  187. package/src/headless/CategoryPicker.tsx +173 -0
  188. package/src/headless/CategoryTree.tsx +115 -0
  189. package/src/i18n/errorsMap.ts +46 -0
  190. package/src/i18n/es.ts +92 -0
  191. package/src/i18n/generated/errors.es.gen.ts +64 -0
  192. package/src/i18n/generated/errors.gen.ts +238 -0
  193. package/src/i18n/generated/errors.json +495 -0
  194. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  195. package/src/i18n/keys.ts +156 -0
  196. package/src/i18n/ru.ts +110 -0
  197. package/src/index.ts +191 -0
  198. package/src/model/catalogStore.ts +102 -0
  199. package/src/model/catalogSync.ts +115 -0
  200. package/src/model/context.tsx +28 -0
  201. package/src/model/queries.ts +226 -0
  202. package/src/model/queryKeys.ts +50 -0
  203. package/src/model/runtime.ts +39 -0
  204. package/src/nav/manifest.ts +66 -0
  205. 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
+ }