@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,134 @@
1
+ /**
2
+ * `<CategoryTreePane>` — one level of the catalogue as a list of links.
3
+ *
4
+ * The four arms of `matchList` are the point of this file, not decoration:
5
+ * "still syncing", "the sync failed", "this category has no sub-categories"
6
+ * and "here they are" are four different sentences, and the third is the one a
7
+ * leaf category legitimately gets. Collapsing any two is the incident
8
+ * `@stapel/core`'s `loadState.ts` was written for.
9
+ *
10
+ * A fifth condition rides alongside them: `truncated`. The sync walk hit its
11
+ * page budget, so what is on screen is a PARTIAL catalogue — which is neither
12
+ * an empty one nor a failed one, and gets its own line rather than being
13
+ * silently indistinguishable from a complete tree.
14
+ */
15
+ import type { ReactElement } from "react";
16
+ import { Alert, Badge, Empty, Flex, List, Spin, Typography } from "antd";
17
+ import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
18
+ import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js";
19
+ import type { CategoryNode } from "../catalog/tree.js";
20
+ import { CategoryTree } from "../headless/CategoryTree.js";
21
+ import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
22
+ import { ErrorAlert } from "./ErrorAlert.js";
23
+ import { CategoriesSkinTheme } from "./theme.js";
24
+ import type { ThemeModeProp } from "./types.js";
25
+
26
+ export interface CategoryTreePaneProps extends ThemeModeProp {
27
+ /** Render this category's children. Omitted renders the roots. */
28
+ readonly parentId?: number | null;
29
+ /** Render the children of the category at this slug (the `/c/:slug` page). */
30
+ readonly slug?: string;
31
+ /** Path prefix for a row's link. Default `/c`. */
32
+ readonly basePath?: string;
33
+ /** Heading above the list. Omitted renders no heading. */
34
+ readonly titleKey?: string;
35
+ }
36
+
37
+ export function CategoryTreePane(props: CategoryTreePaneProps): ReactElement {
38
+ const t = useT();
39
+ const describe = useDescribeFlowError();
40
+ const base = props.basePath ?? "/c";
41
+
42
+ return (
43
+ <CategoriesSkinTheme
44
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
45
+ >
46
+ <CategoryTree
47
+ {...(props.parentId !== undefined ? { parentId: props.parentId } : {})}
48
+ {...(props.slug !== undefined ? { slug: props.slug } : {})}
49
+ >
50
+ {(bag) => (
51
+ <Flex vertical gap={8} data-testid="categories-tree">
52
+ {props.titleKey !== undefined ? (
53
+ <Typography.Title level={5} style={{ margin: 0 }}>
54
+ {t(props.titleKey)}
55
+ </Typography.Title>
56
+ ) : null}
57
+
58
+ {bag.truncated ? (
59
+ <Alert
60
+ type="warning"
61
+ showIcon
62
+ data-testid="categories-tree-truncated"
63
+ message={t(CATEGORIES_I18N_KEYS.catalogTruncated)}
64
+ />
65
+ ) : null}
66
+
67
+ {matchList(bag.state, {
68
+ loading: () => (
69
+ <Flex justify="center" style={{ padding: 16 }}>
70
+ <Spin data-testid="categories-tree-loading" />
71
+ </Flex>
72
+ ),
73
+ failed: (error) => (
74
+ <ErrorAlert
75
+ testId="categories-tree-failed"
76
+ error={{
77
+ ...describe(toFlowError(error)),
78
+ message: t(CATEGORIES_I18N_KEYS.catalogLoadFailed),
79
+ }}
80
+ />
81
+ ),
82
+ empty: () => (
83
+ <Empty
84
+ data-testid="categories-tree-empty"
85
+ description={t(
86
+ bag.current === null
87
+ ? CATEGORIES_I18N_KEYS.catalogEmpty
88
+ : CATEGORIES_I18N_KEYS.categoryNoSubcategories
89
+ )}
90
+ />
91
+ ),
92
+ ready: (nodes) => (
93
+ <List<CategoryNode>
94
+ data-testid="categories-tree-list"
95
+ size="small"
96
+ dataSource={[...nodes]}
97
+ renderItem={(node) => (
98
+ <List.Item key={node.id} data-category-id={node.id}>
99
+ <Flex
100
+ justify="space-between"
101
+ align="center"
102
+ gap={8}
103
+ style={{ width: "100%" }}
104
+ >
105
+ <Typography.Link
106
+ href={`${base}/${node.category.slug}`}
107
+ data-category-slug={node.category.slug}
108
+ >
109
+ {renderCategoryLabel(
110
+ categoryLabel(node.category),
111
+ t
112
+ )}
113
+ </Typography.Link>
114
+ {node.children.length > 0 ? (
115
+ <Badge
116
+ count={node.children.length}
117
+ color="blue"
118
+ title={t(
119
+ CATEGORIES_I18N_KEYS.categorySubcategories
120
+ )}
121
+ />
122
+ ) : null}
123
+ </Flex>
124
+ </List.Item>
125
+ )}
126
+ />
127
+ ),
128
+ })}
129
+ </Flex>
130
+ )}
131
+ </CategoryTree>
132
+ </CategoriesSkinTheme>
133
+ );
134
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The one error surface this pair's default skin renders, so the split copy
3
+ * core produces reaches a screen the same way everywhere (mirrors the
4
+ * docs/notifications/workspaces etalon).
5
+ *
6
+ * Core's `describeFlowError` splits a failure into the sentence a person
7
+ * reads and the technical detail a support agent quotes (owner report
8
+ * 2026-08-09: the status used to be spliced into the sentence, ending in a
9
+ * bare `" (500)"`). The split only helps if a skin renders both halves at
10
+ * their own weights: `message` at normal weight, `detail` muted and small.
11
+ *
12
+ * `undefined` in — including a `detail` core left `undefined` because there
13
+ * was nothing worth quoting — renders nothing rather than an empty line.
14
+ */
15
+ import { Alert, Typography } from "antd";
16
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
17
+ import type { FlowErrorDisplay } from "@stapel/core";
18
+
19
+ export function ErrorAlert(props: {
20
+ error: FlowErrorDisplay | undefined;
21
+ style?: CSSProperties | undefined;
22
+ testId?: string | undefined;
23
+ action?: ReactNode;
24
+ }): ReactElement | null {
25
+ const { error } = props;
26
+ if (!error) return null;
27
+ return (
28
+ <Alert
29
+ type="error"
30
+ showIcon
31
+ {...(props.style ? { style: props.style } : {})}
32
+ {...(props.testId ? { "data-testid": props.testId } : {})}
33
+ {...(props.action ? { action: props.action } : {})}
34
+ message={error.message}
35
+ {...(error.detail
36
+ ? {
37
+ description: (
38
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
39
+ {error.detail}
40
+ </Typography.Text>
41
+ ),
42
+ }
43
+ : {})}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@stapel/categories-react/default` — the antd skin over the headless pair.
3
+ *
4
+ * A separate entry point (the convention every pair's `/default` follows) so a
5
+ * host rendering its own catalogue chrome never pulls `antd` into its bundle.
6
+ * The main entry has no visual opinion at all and no import path from it
7
+ * reaches this directory — size-limit and the bundle-purity test are the
8
+ * teeth on that.
9
+ *
10
+ * ```tsx
11
+ * import { createCategoriesRuntime, CategoriesProvider } from "@stapel/categories-react";
12
+ * import { CatalogPage, CategoryPage } from "@stapel/categories-react/default";
13
+ * ```
14
+ *
15
+ * `CategoryPage` takes `renderListings` — the half of `/c/:slug` that belongs
16
+ * to `@stapel/search-react`, handed in by the container rather than imported
17
+ * across the L2 layer.
18
+ */
19
+ export { CatalogPage } from "./CatalogPage.js";
20
+ export type { CatalogPageProps } from "./CatalogPage.js";
21
+ export { CategoryPage } from "./CategoryPage.js";
22
+ export type { CategoryPageProps } from "./CategoryPage.js";
23
+ export { CategoryTreePane } from "./CategoryTreePane.js";
24
+ export type { CategoryTreePaneProps } from "./CategoryTreePane.js";
25
+ export { CategoryBreadcrumbsBar } from "./CategoryBreadcrumbsBar.js";
26
+ export type { CategoryBreadcrumbsBarProps } from "./CategoryBreadcrumbsBar.js";
27
+ export { CategoryCarouselStrip } from "./CategoryCarouselStrip.js";
28
+ export type { CategoryCarouselStripProps } from "./CategoryCarouselStrip.js";
29
+ export { CategoryPickerField } from "./CategoryPickerField.js";
30
+ export type { CategoryPickerFieldProps } from "./CategoryPickerField.js";
31
+ export { CategoryFeatureList } from "./CategoryFeatureList.js";
32
+ export type { CategoryFeatureListProps } from "./CategoryFeatureList.js";
33
+ export { CategoriesSkinTheme } from "./theme.js";
34
+ export type { CategoriesSkinThemeProps } from "./theme.js";
35
+ export { ErrorAlert } from "./ErrorAlert.js";
36
+ export type { ThemeModeProp } from "./types.js";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The default skin's INTERNAL theme provider — every `/default` surface wraps
3
+ * itself in this, so the skin is readable no matter what the host supplies
4
+ * (tracker #26: a skin set with no internal provider once inherited a theme
5
+ * bridge serving light-mode values inside a dark document — text on
6
+ * background at 1.00:1). The theme derives from `@stapel/tokens` via
7
+ * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
8
+ * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
9
+ * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
10
+ *
11
+ * This is also what "override styles" means for this pair: a storefront
12
+ * retheming through the §68 token JSON regenerates its `--stapel-*` custom
13
+ * properties and the result page follows with zero code — a far better deal
14
+ * than a prop-per-colour API, and the reason the skin has none.
15
+ */
16
+ import { useMemo } from "react";
17
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
18
+ import { ConfigProvider } from "antd";
19
+ import {
20
+ resolveThemeMode,
21
+ toAntdTheme,
22
+ toAntdThemeConfig,
23
+ } from "@stapel/tokens-antd";
24
+ import type { ThemeMode } from "@stapel/tokens-antd";
25
+
26
+ export interface CategoriesSkinThemeProps {
27
+ /**
28
+ * Light or dark. Defaults to the mode the host's document declares
29
+ * (`resolveThemeMode()`). Pass explicitly to pin a side.
30
+ */
31
+ readonly mode?: ThemeMode;
32
+ /** Extra styles merged onto the themed root element (layout only — colors
33
+ * come from the mode). */
34
+ readonly style?: CSSProperties;
35
+ readonly children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * `<CategoriesSkinTheme/>` — the self-theming wrapper of
40
+ * `@stapel/categories-react/default`. Standalone use is supported (wrap any
41
+ * composition of the skin's parts once); the shipped surfaces
42
+ * (`CatalogPage`, `CategoryPage`, `CategoryTreePane`, `CategoryCarouselStrip`,
43
+ * `CategoryPickerField`, `CategoryFeatureList`, `CategoryBreadcrumbsBar`)
44
+ * each already wrap themselves, and nested antd
45
+ * `ConfigProvider`s merge, so composing them under one `CategoriesSkinTheme`
46
+ * stays correct.
47
+ */
48
+ export function CategoriesSkinTheme(props: CategoriesSkinThemeProps): ReactElement {
49
+ const mode = props.mode ?? resolveThemeMode();
50
+ const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
51
+ const token = useMemo(() => toAntdTheme(mode), [mode]);
52
+ return (
53
+ <ConfigProvider theme={theme}>
54
+ <div
55
+ data-categories-skin-root
56
+ data-categories-skin-mode={mode}
57
+ style={{
58
+ color: token.colorText,
59
+ backgroundColor: token.colorBgContainer,
60
+ ...props.style,
61
+ }}
62
+ >
63
+ {props.children}
64
+ </div>
65
+ </ConfigProvider>
66
+ );
67
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Small shared types for the `/default` skin — kept in one place so every
3
+ * surface takes the same `mode` prop and re-exports the same error dialect.
4
+ */
5
+ export type { FlowError } from "@stapel/core";
6
+ import type { ThemeMode } from "@stapel/tokens-antd";
7
+
8
+ /** Every `/default` surface accepts a theme mode; absent means "whatever the
9
+ * host document declares" (`resolveThemeMode()`), never a hardcoded side. */
10
+ export interface ThemeModeProp {
11
+ readonly mode?: ThemeMode;
12
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { ModuleProvider } from "../model/context.js";
3
+ import type { CategoriesRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Provides the wired {@link CategoriesRuntime} to every categories hook and
7
+ * headless component below it. Bring your own visual shell — this component
8
+ * renders nothing of its own. (Core's `createModuleContext` provider, bound to
9
+ * this pair — slim wave §21/S2.)
10
+ *
11
+ * ```tsx
12
+ * const runtime = createCategoriesRuntime({ baseUrl: "/categories/api/v1/" });
13
+ * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
14
+ * <CategoriesProvider runtime={runtime}>{app}</CategoriesProvider>
15
+ * ```
16
+ */
17
+ export const CategoriesProvider: (props: {
18
+ runtime: CategoriesRuntime;
19
+ children: ReactNode;
20
+ }) => ReactElement = ModuleProvider;
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from "react";
2
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import { categoryBreadcrumbs, resolveCategorySlug } from "../catalog/tree.js";
5
+ import type { CategoryNode } from "../catalog/tree.js";
6
+ import { categoryLabel } from "../catalog/labels.js";
7
+ import type { CategoryLabel } from "../catalog/labels.js";
8
+ import { useCategoryCatalog } from "../model/queries.js";
9
+ import type { UseCategoryCatalogOptions } from "../model/queries.js";
10
+
11
+ /** One crumb: the node, and how its caption must be rendered. */
12
+ export interface CategoryCrumb {
13
+ readonly node: CategoryNode;
14
+ /** `kind: "key"` → run `value` through the host's `t`. See
15
+ * `catalog/labels.ts` — names on the wire are translation keys. */
16
+ readonly label: CategoryLabel;
17
+ /** `true` for the last crumb — the page you are on. */
18
+ readonly isCurrent: boolean;
19
+ }
20
+
21
+ export interface CategoryBreadcrumbsBag {
22
+ /**
23
+ * Root → current, inclusive. `empty` means the selected category IS a root
24
+ * (there is nothing above it), which is a different thing from a catalogue
25
+ * that has not loaded and from a slug that does not exist.
26
+ */
27
+ readonly state: LoadState<readonly CategoryCrumb[]>;
28
+ readonly unknownSlug: boolean;
29
+ refetch(): void;
30
+ }
31
+
32
+ export interface CategoryBreadcrumbsProps extends UseCategoryCatalogOptions {
33
+ slug?: string;
34
+ categoryId?: number | null;
35
+ children: (bag: CategoryBreadcrumbsBag) => ReactNode;
36
+ }
37
+
38
+ /**
39
+ * The ancestry of the current category, as data.
40
+ *
41
+ * Walks the BUILT index rather than `tn_ancestors_pks`: a crumb must name a
42
+ * category the visitor can actually open, and the built index is what the
43
+ * visitor is being shown. The server's own column is exposed separately
44
+ * (`categoryAncestorIds`) and the two are cross-checked in the suite, so a
45
+ * divergence is a red test rather than a link to nowhere.
46
+ */
47
+ export function CategoryBreadcrumbs(
48
+ props: CategoryBreadcrumbsProps
49
+ ): ReactNode {
50
+ const { slug, categoryId, children, ...catalogOptions } = props;
51
+ const query = useCategoryCatalog(catalogOptions);
52
+ const catalog = loadStateFromQuery(query);
53
+ const index = catalog.status === "ready" ? catalog.data.index : null;
54
+
55
+ let current: CategoryNode | null = null;
56
+ if (index !== null) {
57
+ if (slug !== undefined) current = resolveCategorySlug(index, slug) ?? null;
58
+ else if (categoryId !== null && categoryId !== undefined) {
59
+ current = index.byId.get(categoryId) ?? null;
60
+ }
61
+ }
62
+
63
+ return children({
64
+ state: mapLoad(catalog, (data) => {
65
+ const path = categoryBreadcrumbs(data.index, current?.id);
66
+ return path.map((node, i) => ({
67
+ node,
68
+ label: categoryLabel(node.category),
69
+ isCurrent: i === path.length - 1,
70
+ }));
71
+ }),
72
+ unknownSlug:
73
+ catalog.status === "ready" && slug !== undefined && current === null,
74
+ refetch: () => {
75
+ void query.refetch();
76
+ },
77
+ });
78
+ }
@@ -0,0 +1,80 @@
1
+ import type { ReactNode } from "react";
2
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import type { Category } from "../api/types.js";
5
+ import { categoryLabel } from "../catalog/labels.js";
6
+ import type { CategoryLabel } from "../catalog/labels.js";
7
+ import { useCategoryCarousel } from "../model/queries.js";
8
+
9
+ /** One carousel tile. */
10
+ export interface CarouselEntry {
11
+ readonly category: Category;
12
+ readonly label: CategoryLabel;
13
+ /**
14
+ * The tile's icon reference — `carousel_icon`, falling back to
15
+ * `catalog_icon`. Both are OPAQUE STRINGS, deliberately decoupled from
16
+ * stapel-cdn ("an opaque string, resolved by the host if at all",
17
+ * `models.py`). This pair does not build a URL out of one: the host knows
18
+ * its CDN base, this library does not, and a guessed URL is a broken image
19
+ * on every deployment that guessed differently.
20
+ */
21
+ readonly icon: string | null;
22
+ /** The storefront path for this tile. Slug-based, because that is the
23
+ * route the spec defines — and the reason the client resolves slugs. */
24
+ readonly href: string;
25
+ }
26
+
27
+ export interface CategoryCarouselBag {
28
+ /**
29
+ * The tiles. `empty` means the catalogue has no `carousel_enabled`
30
+ * categories — a real configuration, and a landing page that says nothing
31
+ * rather than showing a spinner forever.
32
+ */
33
+ readonly state: LoadState<readonly CarouselEntry[]>;
34
+ readonly isFetching: boolean;
35
+ refetch(): void;
36
+ }
37
+
38
+ export interface CategoryCarouselProps {
39
+ /** Path prefix for a tile's link. Default `/c` — the spec's `/c/:slug`. */
40
+ basePath?: string;
41
+ enabled?: boolean;
42
+ children: (bag: CategoryCarouselBag) => ReactNode;
43
+ }
44
+
45
+ /**
46
+ * The landing page's category strip.
47
+ *
48
+ * `GET /categories/carousel/` is the one endpoint that arrives ready to
49
+ * render: the server filters `active` AND `carousel_enabled`, orders by
50
+ * `tn_priority` descending, caches the serialization and sends
51
+ * `Cache-Control: public, max-age`. So this component does no filtering of its
52
+ * own — it maps rows to tiles and says which strings still need translating.
53
+ */
54
+ export function CategoryCarousel(props: CategoryCarouselProps): ReactNode {
55
+ const base = props.basePath ?? "/c";
56
+ const query = useCategoryCarousel(
57
+ props.enabled !== undefined ? { enabled: props.enabled } : {}
58
+ );
59
+ const state = loadStateFromQuery(query);
60
+
61
+ return props.children({
62
+ state: mapLoad(state, (rows) =>
63
+ rows.map((category) => ({
64
+ category,
65
+ label: categoryLabel(category),
66
+ icon:
67
+ category.carousel_icon !== undefined && category.carousel_icon !== ""
68
+ ? category.carousel_icon
69
+ : category.catalog_icon !== undefined && category.catalog_icon !== ""
70
+ ? category.catalog_icon
71
+ : null,
72
+ href: `${base}/${category.slug}`,
73
+ }))
74
+ ),
75
+ isFetching: query.isFetching,
76
+ refetch: () => {
77
+ void query.refetch();
78
+ },
79
+ });
80
+ }
@@ -0,0 +1,100 @@
1
+ import type { ReactNode } from "react";
2
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import { featureType } from "@stapel/attributes-react";
5
+ import type { CategoryFeature } from "../api/types.js";
6
+ import { featureLabel, featureOptionsAreKeys } from "../catalog/labels.js";
7
+ import type { CategoryLabel } from "../catalog/labels.js";
8
+ import { useCategoryFeatures } from "../model/queries.js";
9
+
10
+ /** One feature of a category, with everything a renderer needs decided. */
11
+ export interface CategoryFeatureEntry {
12
+ readonly feature: CategoryFeature;
13
+ /** `kind: "key"` → translate. `translate: "none"` makes it a literal. */
14
+ readonly label: CategoryLabel;
15
+ /** `config.type`, or `undefined` when the row carries none. An undefined
16
+ * type is an UNSUPPORTED type as far as any renderer is concerned —
17
+ * `@stapel/attributes-react` says so loudly rather than skipping the row. */
18
+ readonly type: string | undefined;
19
+ readonly mandatory: boolean;
20
+ /** Are this feature's option labels translation keys? Only under
21
+ * `translate: "all"` and while `translatable_options` is not false. */
22
+ readonly optionsAreKeys: boolean;
23
+ }
24
+
25
+ export interface CategoryFeaturesBag {
26
+ /**
27
+ * The feature schema. `empty` means the category declares no features and
28
+ * inherits none — common for a root category, and a compose form that says
29
+ * "no extra details for this category" rather than spinning.
30
+ */
31
+ readonly state: LoadState<readonly CategoryFeatureEntry[]>;
32
+ /**
33
+ * The raw `FeatureDef[]`, ready to hand straight to
34
+ * `@stapel/attributes-react`'s `<FeatureFields>`, `unsupportedTypeGate`,
35
+ * `mirrorValidate` or `formatFeatureValue`. Kept beside the decorated list
36
+ * because those functions take the wire shape, and re-deriving it from
37
+ * `entries` would be a second mapping to keep in step.
38
+ */
39
+ readonly features: readonly CategoryFeature[];
40
+ /** Only the features marked `show_as_badge` — the card's summary line. */
41
+ readonly badges: readonly CategoryFeature[];
42
+ /** Only the features marked `show_at_title` — the generated title parts. */
43
+ readonly titleParts: readonly CategoryFeature[];
44
+ readonly isFetching: boolean;
45
+ refetch(): void;
46
+ }
47
+
48
+ export interface CategoryFeaturesProps {
49
+ categoryId: number | null | undefined;
50
+ enabled?: boolean;
51
+ children: (bag: CategoryFeaturesBag) => ReactNode;
52
+ }
53
+
54
+ /**
55
+ * A category's feature schema — the bridge to `@stapel/attributes-react`.
56
+ *
57
+ * `GET /categories/{id}/features/` resolves inheritance and order server-side
58
+ * and returns `FeatureCompact` rows whose `config` is the polymorphic
59
+ * attributes config, VERBATIM: `FeatureCompactSerializer.get_config` returns
60
+ * `obj.config`, not `get_config_with_defaults()`. So an absent config key
61
+ * means "the type's default", never "off", and this pair does not restate
62
+ * those defaults — attributes-react owns them (its §13.2 note 1).
63
+ *
64
+ * The two consumers this exists for:
65
+ *
66
+ * - the **compose form** (`listings-react`), which draws the features with
67
+ * attributes-react's editor registry and blocks submit on a type this
68
+ * build cannot draw; and
69
+ * - the **facet panel** (`search-react`), whose `categoryFeatures` slot takes
70
+ * exactly `features` below and uses it to caption facet values.
71
+ *
72
+ * Neither of them re-fetches: a host mounts this once per category page.
73
+ */
74
+ export function CategoryFeatures(props: CategoryFeaturesProps): ReactNode {
75
+ const query = useCategoryFeatures(
76
+ props.categoryId,
77
+ props.enabled !== undefined ? { enabled: props.enabled } : {}
78
+ );
79
+ const state = loadStateFromQuery(query);
80
+ const features = state.status === "ready" ? state.data : [];
81
+
82
+ return props.children({
83
+ state: mapLoad(state, (rows) =>
84
+ rows.map((feature) => ({
85
+ feature,
86
+ label: featureLabel(feature),
87
+ type: featureType(feature),
88
+ mandatory: feature.mandatory === true,
89
+ optionsAreKeys: featureOptionsAreKeys(feature),
90
+ }))
91
+ ),
92
+ features,
93
+ badges: features.filter((f) => f.show_as_badge === true),
94
+ titleParts: features.filter((f) => f.show_at_title === true),
95
+ isFetching: query.isFetching,
96
+ refetch: () => {
97
+ void query.refetch();
98
+ },
99
+ });
100
+ }