@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,173 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
4
+ import type { LoadState } from "@stapel/core";
5
+ import {
6
+ categoryBreadcrumbs,
7
+ flattenCategoryNodes,
8
+ } from "../catalog/tree.js";
9
+ import type { CategoryNode } from "../catalog/tree.js";
10
+ import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js";
11
+ import type { CategoryLabel } from "../catalog/labels.js";
12
+ import { useCategoryCatalog } from "../model/queries.js";
13
+ import type { UseCategoryCatalogOptions } from "../model/queries.js";
14
+
15
+ /** One selectable option in the picker. */
16
+ export interface CategoryOption {
17
+ readonly node: CategoryNode;
18
+ readonly label: CategoryLabel;
19
+ /** Root → this node, for a skin that shows the full path in the option. */
20
+ readonly path: readonly CategoryLabel[];
21
+ /** `true` when this node has no children — usually the only kind a listing
22
+ * may be filed under. See `leavesOnly`. */
23
+ readonly isLeaf: boolean;
24
+ }
25
+
26
+ export interface CategoryPickerBag {
27
+ /**
28
+ * The options at the current drill level, or the search matches while a
29
+ * query is typed. `empty` is a real answer here — a leaf category has no
30
+ * children, and a search can match nothing.
31
+ */
32
+ readonly state: LoadState<readonly CategoryOption[]>;
33
+ /** Where the drill-down currently is: root → open node. */
34
+ readonly path: readonly CategoryNode[];
35
+ /** The selected node, or `null`. */
36
+ readonly selected: CategoryNode | null;
37
+ /** Blocked when nothing is selected, or when the selection is not a leaf
38
+ * and `leavesOnly` is on. Carries the REASON — a disabled control that does
39
+ * not say why is the thing `ActionAvailability` exists to prevent. */
40
+ readonly submitBlockedReason: CategoryPickerBlockedReason | null;
41
+ readonly query: string;
42
+ setQuery(value: string): void;
43
+ /** Descend into a node. A leaf selects instead of descending. */
44
+ open(node: CategoryNode): void;
45
+ /** Back up one level. */
46
+ up(): void;
47
+ select(node: CategoryNode | null): void;
48
+ refetch(): void;
49
+ }
50
+
51
+ /** Why the picker will not hand a value back yet. */
52
+ export type CategoryPickerBlockedReason = "nothing_selected" | "not_a_leaf";
53
+
54
+ export interface CategoryPickerProps extends UseCategoryCatalogOptions {
55
+ /** Controlled selection. */
56
+ value?: number | null;
57
+ onChange?: (id: number | null, node: CategoryNode | null) => void;
58
+ /**
59
+ * Only leaf categories are a valid answer. Default `true`: a listing filed
60
+ * under "Electronics" instead of "Electronics › Phones › Used" inherits the
61
+ * wrong feature set, and the compose form then asks the wrong questions.
62
+ */
63
+ leavesOnly?: boolean;
64
+ /** Resolve a translation key to a caption, so SEARCH matches what the
65
+ * person can read. Without it, search matches the key. */
66
+ translate?: (key: string) => string;
67
+ children: (bag: CategoryPickerBag) => ReactNode;
68
+ }
69
+
70
+ /**
71
+ * The category chooser of the compose form, headless.
72
+ *
73
+ * Two modes over the SAME synced tree: drill-down (roots → children → …) and
74
+ * a flat search. The search matches the RENDERED caption when a `translate` is
75
+ * supplied and the raw key otherwise, which is the only honest behaviour for a
76
+ * catalogue whose names are translation keys (`catalog/labels.ts`): a person
77
+ * typing "phones" is typing what they see, not `category.phones`.
78
+ *
79
+ * Nothing here requests anything — the catalogue is already in memory.
80
+ */
81
+ export function CategoryPicker(props: CategoryPickerProps): ReactNode {
82
+ const {
83
+ value,
84
+ onChange,
85
+ leavesOnly,
86
+ translate,
87
+ children,
88
+ ...catalogOptions
89
+ } = props;
90
+ const requireLeaf = leavesOnly ?? true;
91
+
92
+ const query = useCategoryCatalog(catalogOptions);
93
+ const catalog = loadStateFromQuery(query);
94
+ const index = catalog.status === "ready" ? catalog.data.index : null;
95
+
96
+ const [openId, setOpenId] = useState<number | null>(null);
97
+ const [text, setText] = useState("");
98
+
99
+ const selected =
100
+ index !== null && value !== null && value !== undefined
101
+ ? (index.byId.get(value) ?? null)
102
+ : null;
103
+
104
+ const toOption = useMemo(
105
+ () =>
106
+ (node: CategoryNode): CategoryOption => ({
107
+ node,
108
+ label: categoryLabel(node.category),
109
+ path: (index === null ? [] : categoryBreadcrumbs(index, node.id)).map(
110
+ (crumb) => categoryLabel(crumb.category)
111
+ ),
112
+ isLeaf: node.children.length === 0,
113
+ }),
114
+ [index]
115
+ );
116
+
117
+ const options = useMemo<readonly CategoryOption[]>(() => {
118
+ if (index === null) return [];
119
+ const needle = text.trim().toLocaleLowerCase();
120
+ if (needle !== "") {
121
+ const caption = (node: CategoryNode): string => {
122
+ const label = categoryLabel(node.category);
123
+ return translate === undefined
124
+ ? label.value
125
+ : renderCategoryLabel(label, translate);
126
+ };
127
+ return flattenCategoryNodes(index.roots)
128
+ .filter(
129
+ (node) =>
130
+ caption(node).toLocaleLowerCase().includes(needle) ||
131
+ node.category.slug.toLocaleLowerCase().includes(needle)
132
+ )
133
+ .filter((node) => !requireLeaf || node.children.length === 0)
134
+ .map(toOption);
135
+ }
136
+ const level =
137
+ openId === null ? index.roots : (index.byId.get(openId)?.children ?? []);
138
+ return level.map(toOption);
139
+ }, [index, text, openId, requireLeaf, translate, toOption]);
140
+
141
+ const blocked: CategoryPickerBlockedReason | null =
142
+ selected === null
143
+ ? "nothing_selected"
144
+ : requireLeaf && selected.children.length > 0
145
+ ? "not_a_leaf"
146
+ : null;
147
+
148
+ return children({
149
+ state: mapLoad(catalog, () => options),
150
+ path: index === null ? [] : categoryBreadcrumbs(index, openId),
151
+ selected,
152
+ submitBlockedReason: blocked,
153
+ query: text,
154
+ setQuery: setText,
155
+ open: (node) => {
156
+ if (node.children.length === 0) {
157
+ onChange?.(node.id, node);
158
+ return;
159
+ }
160
+ setOpenId(node.id);
161
+ },
162
+ up: () => {
163
+ if (index === null || openId === null) return;
164
+ setOpenId(index.byId.get(openId)?.category.tn_parent ?? null);
165
+ },
166
+ select: (node) => {
167
+ onChange?.(node?.id ?? null, node);
168
+ },
169
+ refetch: () => {
170
+ void query.refetch();
171
+ },
172
+ });
173
+ }
@@ -0,0 +1,115 @@
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 { CategoryIndex, CategoryNode } from "../catalog/tree.js";
6
+ import { useCategoryCatalog } from "../model/queries.js";
7
+ import type {
8
+ CategoryCatalog,
9
+ UseCategoryCatalogOptions,
10
+ } from "../model/queries.js";
11
+
12
+ /** The bag `<CategoryTree>` hands its render prop. */
13
+ export interface CategoryTreeBag {
14
+ /**
15
+ * The nodes to render at this level. `empty` means the catalogue really has
16
+ * no categories here — reachable only from a sync that succeeded, which is
17
+ * the whole reason it is a separate arm from `failed`.
18
+ */
19
+ readonly state: LoadState<readonly CategoryNode[]>;
20
+ /** The full built catalogue, for a skin that wants more than one level. */
21
+ readonly catalog: LoadState<CategoryCatalog>;
22
+ /** The node `slug`/`categoryId` selected, once the catalogue has loaded. */
23
+ readonly current: CategoryNode | null;
24
+ /** Root → current, inclusive. `[]` when nothing is selected. */
25
+ readonly breadcrumbs: readonly CategoryNode[];
26
+ /**
27
+ * A slug that is not in the loaded catalogue. `true` only once the
28
+ * catalogue is `ready` — before that the honest answer is "we do not know
29
+ * yet", and a skin that renders "category not found" during a sync is
30
+ * showing a 404 for a page that exists.
31
+ */
32
+ readonly unknownSlug: boolean;
33
+ /**
34
+ * The page budget stopped the sync walk: this tree is INCOMPLETE. Distinct
35
+ * from `empty` in the same way `failed` is — a branch missing here was not
36
+ * read, not absent.
37
+ */
38
+ readonly truncated: boolean;
39
+ readonly isFetching: boolean;
40
+ refetch(): void;
41
+ }
42
+
43
+ export interface CategoryTreeProps extends UseCategoryCatalogOptions {
44
+ /** Render the children of this category instead of the roots. */
45
+ parentId?: number | null;
46
+ /** Select a category by slug — the `/c/:slug` route's job. Resolved against
47
+ * the synced tree because the server has no slug lookup. */
48
+ slug?: string;
49
+ /** Select a category by id. Ignored when `slug` is given. */
50
+ categoryId?: number | null;
51
+ children: (bag: CategoryTreeBag) => ReactNode;
52
+ }
53
+
54
+ function levelOf(
55
+ index: CategoryIndex,
56
+ parentId: number | null | undefined,
57
+ current: CategoryNode | null
58
+ ): readonly CategoryNode[] {
59
+ if (parentId !== null && parentId !== undefined) {
60
+ return index.byId.get(parentId)?.children ?? [];
61
+ }
62
+ // With a category selected and no explicit parent, "the level" is that
63
+ // category's own sub-categories — which is what a category page shows. A
64
+ // LEAF category has none, and that empty is a true empty: the skin below it
65
+ // renders listings, not a menu.
66
+ if (current !== null) return current.children;
67
+ return index.roots;
68
+ }
69
+
70
+ /**
71
+ * Headless category navigation over the delta-synced catalogue.
72
+ *
73
+ * One hook mounted anywhere in the tree serves every level: the roots on the
74
+ * catalogue page, one category's children on `/c/:slug`, the breadcrumb above
75
+ * either. Nothing here issues a request per level — the tree is already in
76
+ * memory (see `useCategoryCatalog`), which is precisely the property the spec
77
+ * asked for in §4.3.
78
+ */
79
+ export function CategoryTree(props: CategoryTreeProps): ReactNode {
80
+ const {
81
+ parentId,
82
+ slug,
83
+ categoryId,
84
+ children,
85
+ ...catalogOptions
86
+ } = props;
87
+ const query = useCategoryCatalog(catalogOptions);
88
+ const catalog = loadStateFromQuery(query);
89
+ const index = catalog.status === "ready" ? catalog.data.index : null;
90
+
91
+ let current: CategoryNode | null = null;
92
+ if (index !== null) {
93
+ if (slug !== undefined) current = resolveCategorySlug(index, slug) ?? null;
94
+ else if (categoryId !== null && categoryId !== undefined) {
95
+ current = index.byId.get(categoryId) ?? null;
96
+ }
97
+ }
98
+
99
+ return children({
100
+ state: mapLoad(catalog, (data) =>
101
+ levelOf(data.index, parentId, current)
102
+ ),
103
+ catalog,
104
+ current,
105
+ breadcrumbs:
106
+ index === null ? [] : categoryBreadcrumbs(index, current?.id),
107
+ unknownSlug:
108
+ catalog.status === "ready" && slug !== undefined && current === null,
109
+ truncated: catalog.status === "ready" && catalog.data.truncated,
110
+ isFetching: query.isFetching,
111
+ refetch: () => {
112
+ void query.refetch();
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7, frontend-core
3
+ * §2.5): the generated `code → { status, params, remediation, en }` catalog
4
+ * plus a tiny `explain()` lookup. Backs the manifest `errors` block and gives
5
+ * hosts a mechanical UX branch beside `t(code, params)`. The map itself is
6
+ * generated from the backend registry (`pnpm gen:errors`); this file only adds
7
+ * the lookup helper and re-exports the public surface.
8
+ */
9
+ import { CATEGORIES_ERRORS } from "./generated/errors.gen.js";
10
+ import type { Remediation } from "./generated/errors.gen.js";
11
+
12
+ export {
13
+ CATEGORIES_ERRORS,
14
+ CATEGORIES_ERROR_CODES,
15
+ categoriesErrorBundleEn,
16
+ } from "./generated/errors.gen.js";
17
+ export type {
18
+ CategoriesErrorCode,
19
+ CategoriesErrorSpec,
20
+ Remediation,
21
+ } from "./generated/errors.gen.js";
22
+
23
+ /**
24
+ * The optimistic-concurrency refusal of the feature editor, named because it
25
+ * is the one 409 in the registry and it means something specific: somebody
26
+ * else changed this category's schema while you were editing it, and
27
+ * `params.expected` / `params.actual` say which revisions.
28
+ *
29
+ * Not raised by anything on this pair's surface (the feature editor is a staff
30
+ * screen — see `api/categoriesApi.ts`), and exported anyway because the
31
+ * catalogue admin a host builds on top of this client WILL meet it, and a
32
+ * constant beats a string retyped at each call site.
33
+ */
34
+ export const CATEGORIES_FEATURE_EDITOR_CONFLICT =
35
+ "error.409.categories_feature_editor_conflict";
36
+
37
+ /**
38
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
39
+ * code this module doesn't know (e.g. a cross-cutting `stapel.http.*`
40
+ * fallback). Zero guessing at runtime — a static lookup over the generated map.
41
+ */
42
+ export function explainCategoriesError(code: string): Remediation | undefined {
43
+ return (CATEGORIES_ERRORS as Record<string, { remediation: Remediation }>)[
44
+ code
45
+ ]?.remediation;
46
+ }
package/src/i18n/es.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { categoriesErrorBundleEs } from "./generated/errors.es.gen.js";
3
+
4
+ export { categoriesErrorBundleEs } from "./generated/errors.es.gen.js";
5
+
6
+ /**
7
+ * Spanish bundle for categories-react — the
8
+ * `@stapel/categories-react/i18n/es` subpath (i18n-shipping.md §2), opt-in
9
+ * exactly like `ru`.
10
+ *
11
+ * Same composition and the same three-way split by owner as `./ru`: the 42
12
+ * cross-cutting `stapel_core` codes are generated from core's catalogue, the 8
13
+ * `stapel_categories` codes are pair-authored here until upstream ships a
14
+ * `translations/` directory, and the 12 `stapel_attributes` codes stay with
15
+ * `@stapel/attributes-react`, which owns them.
16
+ *
17
+ * `es` carries the full UI copy, not just the refusals: the catalogue is the
18
+ * first thing a visitor sees, and a half-translated menu above a translated
19
+ * search is worse than either.
20
+ *
21
+ * PROVENANCE: unreviewed, seed-grade, like every other authored catalogue in
22
+ * this wave.
23
+ */
24
+ export const categoriesI18nBundleEs: I18nDictionary = {
25
+ ...categoriesErrorBundleEs,
26
+
27
+ // ── the 8 stapel_categories-owned codes, pair-authored ───────────────────
28
+ "error.400.categories_config_required": "Se requiere un objeto config.",
29
+ "error.400.categories_database_error":
30
+ "Error de base de datos al aplicar los cambios.",
31
+ "error.400.categories_duplicate_slug":
32
+ "Ya existe una característica con el slug «{slug}».",
33
+ "error.400.categories_expected_list": "Se esperaba una lista de objetos.",
34
+ "error.400.categories_feature_editor_invalid":
35
+ "Solicitud del editor de características no válida: {reason}",
36
+ "error.400.categories_invalid_conversion":
37
+ "Conversión de tipo no válida (solo se admite select ↔ string).",
38
+ "error.400.categories_not_deleted": "La categoría no está eliminada.",
39
+ "error.409.categories_feature_editor_conflict":
40
+ "Otro editor modificó la categoría (se esperaba la revisión {expected}, ahora {actual}); recarga y vuelve a intentarlo.",
41
+
42
+ "categories.error.unknown": "Algo salió mal con el catálogo.",
43
+
44
+ "categories.catalog.title": "Catálogo",
45
+ "categories.catalog.loading": "Cargando el catálogo…",
46
+ "categories.catalog.load_failed": "No pudimos cargar el catálogo.",
47
+ "categories.catalog.empty": "Este catálogo todavía no tiene categorías.",
48
+ "categories.catalog.retry": "Reintentar",
49
+ "categories.catalog.truncated":
50
+ "Solo se cargó parte del catálogo. Una categoría que falte aquí aún no se ha leído: no se ha eliminado.",
51
+ "categories.catalog.refreshing": "Comprobando cambios en el catálogo…",
52
+ "categories.catalog.as_of": "Catálogo a la revisión {revision}",
53
+
54
+ "categories.category.title": "Categoría",
55
+ "categories.category.unknown_slug": "No hay ninguna categoría en esta dirección.",
56
+ "categories.category.subcategories": "Subcategorías",
57
+ "categories.category.no_subcategories": "Esta categoría no tiene subcategorías.",
58
+ "categories.category.open": "Abrir",
59
+
60
+ "categories.breadcrumbs.root": "Todas las categorías",
61
+ "categories.breadcrumbs.label": "Estás aquí",
62
+
63
+ "categories.carousel.title": "Explorar por categoría",
64
+ "categories.carousel.loading": "Cargando categorías…",
65
+ "categories.carousel.load_failed": "No pudimos cargar las categorías.",
66
+ "categories.carousel.empty": "Ahora mismo no hay categorías destacadas.",
67
+
68
+ "categories.picker.title": "Categoría",
69
+ "categories.picker.search": "Buscar categorías",
70
+ "categories.picker.loading": "Cargando categorías…",
71
+ "categories.picker.load_failed": "No pudimos cargar las categorías.",
72
+ "categories.picker.no_matches": "Ninguna categoría coincide.",
73
+ "categories.picker.up": "Subir un nivel",
74
+ "categories.picker.selected": "Seleccionada: {category}",
75
+ "categories.picker.blocked.nothing_selected": "Elige primero una categoría.",
76
+ "categories.picker.blocked.not_a_leaf":
77
+ "Elige una categoría más concreta: esta tiene subcategorías, y de ellas dependen los datos que se piden.",
78
+
79
+ "categories.features.title": "Datos de esta categoría",
80
+ "categories.features.loading": "Cargando los datos…",
81
+ "categories.features.load_failed": "No pudimos cargar los datos.",
82
+ "categories.features.empty": "Esta categoría no pide datos adicionales.",
83
+ "categories.features.mandatory": "Obligatorio",
84
+ "categories.features.type": "{type}",
85
+ "categories.features.untyped": "Este dato no tiene tipo y no se puede mostrar.",
86
+ };
87
+
88
+ /** Register the `es` bundle. Call AFTER `registerCategoriesI18n` so it
89
+ * overrides the English floor. */
90
+ export function registerCategoriesI18nEs(engine: I18nEngine, locale = "es"): void {
91
+ engine.registerBundle(locale, categoriesI18nBundleEs);
92
+ }
@@ -0,0 +1,64 @@
1
+ // AUTO-GENERATED by scripts/gen-errors.mjs — do not edit by hand.
2
+ // Source: stapel-auth canonical errors.json (backend codegen artifact).
3
+ // Regenerate: pnpm gen:errors · Drift gate: pnpm gen:errors:check
4
+ // Locale source: backend translations/errors.es.json (locale catalog
5
+ // beside the canonical errors.json — i18n-shipping.md §1).
6
+ import type { CategoriesErrorCode } from "./errors.gen.js";
7
+
8
+ /**
9
+ * `es` texts for the backend error codes this catalog carries.
10
+ *
11
+ * PARTIAL, and deliberately typed to say so: 20 key(s) owned by
12
+ * stapel_attributes, stapel_categories are absent, because that owner ships no locale catalog
13
+ * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
+ * artifact via the en bundle; the pair layers its own authored `es`
15
+ * strings over this one until upstream ships translations. `Partial` is what
16
+ * makes the gap visible to TypeScript instead of a silent English fallback.
17
+ *
18
+ * Import from the pair's `./i18n/es` subpath, NOT the main entry, so
19
+ * the locale stays out of hosts that don't ship it.
20
+ */
21
+ export const categoriesErrorBundleEs: Partial<Record<CategoriesErrorCode, string>> = {
22
+ "error.400.bad_request": "Solicitud incorrecta",
23
+ "error.400.captcha_invalid": "La verificación del captcha ha fallado. Inténtalo de nuevo.",
24
+ "error.400.captcha_required": "Se requiere el token del captcha.",
25
+ "error.400.expected_list": "Se esperaba una lista de elementos",
26
+ "error.400.field.blank": "{field} no puede estar vacío",
27
+ "error.400.field.does_not_exist": "{field} no existe",
28
+ "error.400.field.invalid": "{field} no es válido",
29
+ "error.400.field.invalid_choice": "{field} no es una opción válida",
30
+ "error.400.field.max_length": "{field} debe tener como máximo {max_length} caracteres",
31
+ "error.400.field.max_value": "{field} debe ser como máximo {max_value}",
32
+ "error.400.field.min_length": "{field} debe tener al menos {min_length} caracteres",
33
+ "error.400.field.min_value": "{field} debe ser como mínimo {min_value}",
34
+ "error.400.field.null": "{field} no puede ser nulo",
35
+ "error.400.field.required": "{field} es obligatorio",
36
+ "error.400.field.unique": "{field} debe ser único",
37
+ "error.400.invalid_ad_id": "ID de anuncio no válido",
38
+ "error.400.validation_error": "Error de validación",
39
+ "error.400.verification_failed": "La verificación ha fallado",
40
+ "error.400.verification_invalid_factor": "Este factor de verificación no está disponible",
41
+ "error.401.unauthorized": "Se requiere autenticación",
42
+ "error.402.payment_required": "Se requiere pago",
43
+ "error.403.forbidden": "No tienes permiso para realizar esta acción",
44
+ "error.403.network_blocked": "No se permiten solicitudes desde esta red.",
45
+ "error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
46
+ "error.403.verification_required": "Se requiere verificación adicional",
47
+ "error.404.ad_not_found": "Anuncio no encontrado",
48
+ "error.404.not_found": "Recurso solicitado no encontrado",
49
+ "error.404.verification_challenge_not_found": "Desafío de verificación no encontrado o caducado",
50
+ "error.405.method_not_allowed": "Método no permitido",
51
+ "error.406.not_acceptable": "No aceptable",
52
+ "error.408.request_timeout": "Tiempo de espera de la solicitud agotado",
53
+ "error.409.conflict": "El recurso ya existe",
54
+ "error.410.gone": "El recurso se ha eliminado permanentemente",
55
+ "error.413.payload_too_large": "El cuerpo de la solicitud es demasiado grande",
56
+ "error.415.unsupported_media_type": "Tipo de contenido no compatible",
57
+ "error.422.unprocessable_entity": "Entidad no procesable",
58
+ "error.423.locked": "El recurso está bloqueado",
59
+ "error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
60
+ "error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
61
+ "error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
62
+ "error.500.internal": "Algo salió mal",
63
+ "error.503.mandate_unavailable": "No se puede verificar el mandato del espacio de trabajo",
64
+ };