@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,156 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { categoriesErrorBundleEn } from "./generated/errors.gen.js";
3
+
4
+ /**
5
+ * categories-react's own translation KEYS (frontend-standard §4.2): headless
6
+ * components never render literal strings — hosts resolve these via core's
7
+ * i18n engine (`useT`). Backend error codes flow through the SAME contour: a
8
+ * `StapelApiError.code` is already a key, so the default bundle below ships
9
+ * English fallbacks for the 62 backend error codes (generated) plus the pair's
10
+ * own UI keys. All UI keys live under the `categories.` namespace.
11
+ *
12
+ * ── WHAT IS DELIBERATELY NOT IN THIS FILE ──────────────────────────────────
13
+ *
14
+ * **A category name.** `category.electronics` is a key, and it is a key this
15
+ * library must never carry a text for: the catalogue is a deployment's
16
+ * content, not a library's chrome, and no two marketplaces have the same tree.
17
+ * `catalog/labels.ts` explains the mechanism in full — names arrive as keys
18
+ * because no serializer runs the module's `DISPLAY_TRANSLATOR` seam — and the
19
+ * pair's answer is to hand the key to the HOST's engine, whose bundle is where
20
+ * a deployment's catalogue copy belongs. When the key does not resolve the key
21
+ * is shown, on purpose: a visible `category.electronics` gets fixed, while a
22
+ * prettified "Electronics" ships for a year in the wrong language.
23
+ *
24
+ * The same goes for a feature's name and its option labels.
25
+ *
26
+ * **The twelve `error.400.feature_*` / `description_too_*` keys.** They are
27
+ * owned by `stapel_attributes` and translated by `@stapel/attributes-react`
28
+ * (`/i18n/ru`, `/i18n/es`) — the package that also draws and validates those
29
+ * values. Restating them here would give one refusal two sentences, which is
30
+ * the exact thing §13.2's note 3 closed. A host registers both bundles; the
31
+ * suite proves the union covers the whole registry.
32
+ */
33
+ export const CATEGORIES_I18N_KEYS = {
34
+ unknownError: "categories.error.unknown",
35
+
36
+ // ── catalog / tree ───────────────────────────────────────────────────────
37
+ catalogTitle: "categories.catalog.title",
38
+ catalogLoading: "categories.catalog.loading",
39
+ catalogLoadFailed: "categories.catalog.load_failed",
40
+ catalogEmpty: "categories.catalog.empty",
41
+ catalogRetry: "categories.catalog.retry",
42
+ /** The sync walk hit its page budget — the tree on screen is incomplete. */
43
+ catalogTruncated: "categories.catalog.truncated",
44
+ catalogRefreshing: "categories.catalog.refreshing",
45
+ catalogAsOf: "categories.catalog.as_of",
46
+
47
+ // ── one category ─────────────────────────────────────────────────────────
48
+ categoryTitle: "categories.category.title",
49
+ categoryUnknownSlug: "categories.category.unknown_slug",
50
+ categorySubcategories: "categories.category.subcategories",
51
+ /** A leaf: no sub-categories, and that is not an error. */
52
+ categoryNoSubcategories: "categories.category.no_subcategories",
53
+ categoryOpen: "categories.category.open",
54
+
55
+ // ── breadcrumbs ──────────────────────────────────────────────────────────
56
+ breadcrumbsRoot: "categories.breadcrumbs.root",
57
+ breadcrumbsLabel: "categories.breadcrumbs.label",
58
+
59
+ // ── carousel ─────────────────────────────────────────────────────────────
60
+ carouselTitle: "categories.carousel.title",
61
+ carouselLoading: "categories.carousel.loading",
62
+ carouselLoadFailed: "categories.carousel.load_failed",
63
+ carouselEmpty: "categories.carousel.empty",
64
+
65
+ // ── picker (the compose form's chooser) ──────────────────────────────────
66
+ pickerTitle: "categories.picker.title",
67
+ pickerSearch: "categories.picker.search",
68
+ pickerLoading: "categories.picker.loading",
69
+ pickerLoadFailed: "categories.picker.load_failed",
70
+ pickerNoMatches: "categories.picker.no_matches",
71
+ pickerUp: "categories.picker.up",
72
+ pickerSelected: "categories.picker.selected",
73
+ pickerBlockedNothingSelected: "categories.picker.blocked.nothing_selected",
74
+ pickerBlockedNotALeaf: "categories.picker.blocked.not_a_leaf",
75
+
76
+ // ── feature schema ───────────────────────────────────────────────────────
77
+ featuresTitle: "categories.features.title",
78
+ featuresLoading: "categories.features.loading",
79
+ featuresLoadFailed: "categories.features.load_failed",
80
+ featuresEmpty: "categories.features.empty",
81
+ featuresMandatory: "categories.features.mandatory",
82
+ featuresType: "categories.features.type",
83
+ /** A feature whose `config` carries no `type` at all. */
84
+ featuresUntyped: "categories.features.untyped",
85
+ } as const;
86
+
87
+ export type CategoriesI18nKey =
88
+ (typeof CATEGORIES_I18N_KEYS)[keyof typeof CATEGORIES_I18N_KEYS];
89
+
90
+ /**
91
+ * English fallback bundle for categories-react UI keys + backend error codes.
92
+ * The generated backend texts are spread FIRST so coverage of the error
93
+ * registry is by construction; the pair's own copy follows.
94
+ */
95
+ export const categoriesI18nBundleEn: Record<string, string> = {
96
+ ...categoriesErrorBundleEn,
97
+
98
+ "categories.error.unknown": "Something went wrong with the catalogue",
99
+
100
+ "categories.catalog.title": "Catalogue",
101
+ "categories.catalog.loading": "Loading the catalogue…",
102
+ "categories.catalog.load_failed": "We could not load the catalogue",
103
+ "categories.catalog.empty": "This catalogue has no categories yet",
104
+ "categories.catalog.retry": "Try again",
105
+ "categories.catalog.truncated":
106
+ "Only part of the catalogue was loaded. A category missing here has not been read yet — it has not been removed.",
107
+ "categories.catalog.refreshing": "Checking for catalogue changes…",
108
+ "categories.catalog.as_of": "Catalogue as of revision {revision}",
109
+
110
+ "categories.category.title": "Category",
111
+ "categories.category.unknown_slug":
112
+ "There is no category at this address",
113
+ "categories.category.subcategories": "Subcategories",
114
+ "categories.category.no_subcategories": "This category has no subcategories",
115
+ "categories.category.open": "Open",
116
+
117
+ "categories.breadcrumbs.root": "All categories",
118
+ "categories.breadcrumbs.label": "You are here",
119
+
120
+ "categories.carousel.title": "Browse by category",
121
+ "categories.carousel.loading": "Loading categories…",
122
+ "categories.carousel.load_failed": "We could not load the categories",
123
+ "categories.carousel.empty": "No categories are featured right now",
124
+
125
+ "categories.picker.title": "Category",
126
+ "categories.picker.search": "Search categories",
127
+ "categories.picker.loading": "Loading categories…",
128
+ "categories.picker.load_failed": "We could not load the categories",
129
+ "categories.picker.no_matches": "No category matches that",
130
+ "categories.picker.up": "Up one level",
131
+ "categories.picker.selected": "Selected: {category}",
132
+ "categories.picker.blocked.nothing_selected": "Choose a category first",
133
+ "categories.picker.blocked.not_a_leaf":
134
+ "Choose a more specific category — this one has subcategories, and the details asked for depend on which",
135
+
136
+ "categories.features.title": "Details in this category",
137
+ "categories.features.loading": "Loading the details…",
138
+ "categories.features.load_failed": "We could not load the details",
139
+ "categories.features.empty": "This category asks for no extra details",
140
+ "categories.features.mandatory": "Required",
141
+ "categories.features.type": "{type}",
142
+ "categories.features.untyped": "This detail has no type and cannot be shown",
143
+ };
144
+
145
+ /**
146
+ * Register the English bundle into a core i18n engine. Locale bundles ship as
147
+ * opt-in subpaths (`@stapel/categories-react/i18n/ru`, `…/es`) so a host that
148
+ * needs only English never carries them.
149
+ *
150
+ * A host that renders a category's feature schema also registers
151
+ * `@stapel/attributes-react`'s bundles — that package owns the twelve
152
+ * `stapel_attributes` error keys in every locale.
153
+ */
154
+ export function registerCategoriesI18n(i18n: I18nEngine): void {
155
+ i18n.registerBundle("en", categoriesI18nBundleEn as I18nDictionary);
156
+ }
package/src/i18n/ru.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { categoriesErrorBundleRu } from "./generated/errors.ru.gen.js";
3
+
4
+ export { categoriesErrorBundleRu } from "./generated/errors.ru.gen.js";
5
+
6
+ /**
7
+ * Russian bundle for categories-react — the `@stapel/categories-react/i18n/ru`
8
+ * subpath (i18n-shipping.md §2) so the locale is opt-in: hosts that do not
9
+ * register it never carry these strings (the main entry does not import this
10
+ * module — gated by size-limit + the bundle-purity test).
11
+ *
12
+ * This is the storefront's DEFAULT language (storefront spec verdict F1:
13
+ * ru-first).
14
+ *
15
+ * ── What comes from where ──────────────────────────────────────────────────
16
+ *
17
+ * `stapel-categories` ships **no `translations/` directory at all**, so 20 of
18
+ * the 62 registry codes can never come from an upstream catalogue. They split
19
+ * by owner, and the split is the whole point:
20
+ *
21
+ * - the 42 cross-cutting `stapel_core` codes are GENERATED, merged in from
22
+ * stapel-core's own catalogue by `pnpm gen:errors` (spread first below);
23
+ * - the **8 `stapel_categories` codes** are authored here — and when upstream
24
+ * ships a catalogue, eight lines are deleted and no key or text moves;
25
+ * - the **12 `stapel_attributes` codes** are NOT here. They belong to
26
+ * `@stapel/attributes-react`, which already translates them and which is a
27
+ * peer of this pair anyway. Two pairs must not give one refusal two
28
+ * sentences (§13.2 note 3). A host registers both bundles;
29
+ * `test/i18n.test.ts` proves the union covers the registry.
30
+ *
31
+ * PROVENANCE, stated rather than implied: the core catalogue ships
32
+ * `origin=seed:authored` and is UNREVIEWED; the pair-authored strings below
33
+ * are the same grade. Neither is a claim of review.
34
+ *
35
+ * And once more, because it is the fact this pair exists to be honest about:
36
+ * **no category name is translated here.** Category and feature names arrive
37
+ * as translation KEYS (`catalog/labels.ts`) and belong in the deployment's own
38
+ * bundle, not in a library's.
39
+ */
40
+ export const categoriesI18nBundleRu: I18nDictionary = {
41
+ ...categoriesErrorBundleRu,
42
+
43
+ // ── the 8 stapel_categories-owned codes, pair-authored ───────────────────
44
+ "error.400.categories_config_required": "Требуется объект config.",
45
+ "error.400.categories_database_error":
46
+ "Ошибка базы данных при сохранении изменений.",
47
+ "error.400.categories_duplicate_slug":
48
+ "Характеристика со слагом «{slug}» уже существует.",
49
+ "error.400.categories_expected_list": "Ожидался список объектов.",
50
+ "error.400.categories_feature_editor_invalid":
51
+ "Некорректный запрос редактора характеристик: {reason}",
52
+ "error.400.categories_invalid_conversion":
53
+ "Недопустимое преобразование типа (поддерживается только select ↔ string).",
54
+ "error.400.categories_not_deleted": "Категория не удалена.",
55
+ "error.409.categories_feature_editor_conflict":
56
+ "Категорию изменил другой редактор (ожидалась ревизия {expected}, сейчас {actual}); перезагрузите страницу и повторите.",
57
+
58
+ "categories.error.unknown": "Что-то пошло не так с каталогом.",
59
+
60
+ "categories.catalog.title": "Каталог",
61
+ "categories.catalog.loading": "Загружаем каталог…",
62
+ "categories.catalog.load_failed": "Не удалось загрузить каталог.",
63
+ "categories.catalog.empty": "В каталоге пока нет категорий.",
64
+ "categories.catalog.retry": "Повторить",
65
+ "categories.catalog.truncated":
66
+ "Каталог загружен не полностью. Категории, которой здесь нет, ещё не прочитали — её не удаляли.",
67
+ "categories.catalog.refreshing": "Проверяем изменения в каталоге…",
68
+ "categories.catalog.as_of": "Каталог на ревизию {revision}",
69
+
70
+ "categories.category.title": "Категория",
71
+ "categories.category.unknown_slug": "По этому адресу нет категории.",
72
+ "categories.category.subcategories": "Подкатегории",
73
+ "categories.category.no_subcategories": "У этой категории нет подкатегорий.",
74
+ "categories.category.open": "Открыть",
75
+
76
+ "categories.breadcrumbs.root": "Все категории",
77
+ "categories.breadcrumbs.label": "Вы здесь",
78
+
79
+ "categories.carousel.title": "Категории",
80
+ "categories.carousel.loading": "Загружаем категории…",
81
+ "categories.carousel.load_failed": "Не удалось загрузить категории.",
82
+ "categories.carousel.empty": "Сейчас ни одна категория не вынесена на витрину.",
83
+
84
+ "categories.picker.title": "Категория",
85
+ "categories.picker.search": "Поиск по категориям",
86
+ "categories.picker.loading": "Загружаем категории…",
87
+ "categories.picker.load_failed": "Не удалось загрузить категории.",
88
+ "categories.picker.no_matches": "Ни одна категория не подходит.",
89
+ "categories.picker.up": "На уровень выше",
90
+ "categories.picker.selected": "Выбрано: {category}",
91
+ "categories.picker.blocked.nothing_selected": "Сначала выберите категорию.",
92
+ "categories.picker.blocked.not_a_leaf":
93
+ "Выберите категорию точнее — у этой есть подкатегории, а от них зависит, какие характеристики спросят.",
94
+
95
+ "categories.features.title": "Характеристики в этой категории",
96
+ "categories.features.loading": "Загружаем характеристики…",
97
+ "categories.features.load_failed": "Не удалось загрузить характеристики.",
98
+ "categories.features.empty":
99
+ "В этой категории не нужно заполнять дополнительные характеристики.",
100
+ "categories.features.mandatory": "Обязательно",
101
+ "categories.features.type": "{type}",
102
+ "categories.features.untyped":
103
+ "У этой характеристики нет типа, показать её нельзя.",
104
+ };
105
+
106
+ /** Register the `ru` bundle. Call AFTER `registerCategoriesI18n` so it
107
+ * overrides the English floor. */
108
+ export function registerCategoriesI18nRu(engine: I18nEngine, locale = "ru"): void {
109
+ engine.registerBundle(locale, categoriesI18nBundleRu);
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `@stapel/categories-react` — the headless React pair for stapel-categories
3
+ * (frontend-standard §2). Business + state only, zero visual opinion; the antd
4
+ * skin lives behind the `./default` subpath, so a host that renders its own
5
+ * catalogue chrome never carries it.
6
+ *
7
+ * ── The one-liner ──────────────────────────────────────────────────────────
8
+ *
9
+ * ```tsx
10
+ * const runtime = createCategoriesRuntime({ baseUrl: "/categories/api/v1/" });
11
+ * <CategoriesProvider runtime={runtime}>
12
+ * <CatalogPage /> // or <CategoryPage slug={slug} renderListings={…} />
13
+ * </CategoriesProvider>
14
+ * ```
15
+ *
16
+ * No session, no workspace id, no auth client: every endpoint this pair calls
17
+ * is a SAFE method under `ReadOnlyOrStaff`, so a catalogue renders for a
18
+ * visitor who will never sign in.
19
+ *
20
+ * ── The four properties this pair exists to guarantee ──────────────────────
21
+ *
22
+ * 1. **The client builds the tree, because the server has none.**
23
+ * `GET /categories/` returns FLAT rows ordered by `revision`, with treenode
24
+ * ancestry as COMMA-JOINED pk strings (`"1,7,12"`, `""` for a root).
25
+ * `catalog/tree.ts` parses and assembles; `catalog/pks.ts` is the parser,
26
+ * and it exists because the spec described those columns as arrays.
27
+ * 2. **The catalogue is synced by delta, not refetched.** A stored snapshot
28
+ * (`createRepository`, `scope: "app"`) plus `?min_revision=` is the module's
29
+ * own documented protocol; without it every storefront page pulls the whole
30
+ * catalogue. Two things the documentation does not say are implemented
31
+ * anyway and explained where they live: `revisions.deleted_ids` is the
32
+ * complete tombstone channel (the `deleted: true` rows are paginated and a
33
+ * short walk misses them), and a multi-page walk must pin `max_revision` or
34
+ * a concurrent write shifts its page boundaries.
35
+ * 3. **A slug is resolved by the client, because the server cannot.**
36
+ * `lookup_field` is never overridden and the list endpoint has no slug
37
+ * filter, so `/c/:slug` resolves against the synced tree. That is a second,
38
+ * independent reason the tree is cached.
39
+ * 4. **Names are translation KEYS, and this pair says so.** No serializer runs
40
+ * the module's `DISPLAY_TRANSLATOR` seam, so `name` arrives as
41
+ * `category.electronics` even on a deployment with a real translator
42
+ * configured, and `translatable` / `translate` say per row whether a string
43
+ * is a key at all. `catalog/labels.ts` carries the whole answer, and the
44
+ * pair ships no catalogue of category names — those belong to the host.
45
+ *
46
+ * Layers: api → catalog (pure) → model → headless → i18n. Generated surfaces
47
+ * (the typed schema, the error map, the manifest, the nav manifest, llms.txt)
48
+ * are produced by the monorepo `gen:*` drivers from stapel-categories' own
49
+ * `docs/` artifacts and stand under drift gates.
50
+ */
51
+
52
+ // ── api ──────────────────────────────────────────────────────────────────────
53
+ export { categoryListParams, createCategoriesApi } from "./api/categoriesApi.js";
54
+ export type { CategoriesApi } from "./api/categoriesApi.js";
55
+ export type {
56
+ Category,
57
+ CategoryFeature,
58
+ CategoryListParams,
59
+ CategoryPage as CategoryListPage,
60
+ CategoryRevisions,
61
+ FeatureConfig,
62
+ MaxRevision,
63
+ Schemas,
64
+ } from "./api/types.js";
65
+
66
+ // ── catalog (pure: no React, no fetch, no storage) ───────────────────────────
67
+ export { parseTreenodePks } from "./catalog/pks.js";
68
+ export {
69
+ buildCategoryTree,
70
+ categoryAncestorIds,
71
+ categoryBreadcrumbs,
72
+ categoryChildIds,
73
+ flattenCategoryNodes,
74
+ resolveCategorySlug,
75
+ } from "./catalog/tree.js";
76
+ export type {
77
+ BuildCategoryTreeOptions,
78
+ CategoryIndex,
79
+ CategoryNode,
80
+ } from "./catalog/tree.js";
81
+
82
+ export {
83
+ EMPTY_SNAPSHOT,
84
+ applyCategoryPage,
85
+ firstPageRequest,
86
+ isEmptySnapshot,
87
+ nextPageRequest,
88
+ parseSnapshot,
89
+ } from "./catalog/sync.js";
90
+ export type { CategorySnapshot } from "./catalog/sync.js";
91
+
92
+ export {
93
+ categoryLabel,
94
+ featureLabel,
95
+ featureOptionsAreKeys,
96
+ renderCategoryLabel,
97
+ } from "./catalog/labels.js";
98
+ export type { CategoryLabel, CategoryLabelKind } from "./catalog/labels.js";
99
+
100
+ // ── model (runtime wiring, persistence, sync driver, hooks) ──────────────────
101
+ export { createCategoriesRuntime } from "./model/runtime.js";
102
+ export type {
103
+ CategoriesRuntime,
104
+ CreateCategoriesRuntimeOptions,
105
+ } from "./model/runtime.js";
106
+ export {
107
+ CategoriesRuntimeContext,
108
+ useCategoriesAnalytics,
109
+ useCategoriesApi,
110
+ useCategoriesRuntime,
111
+ } from "./model/context.js";
112
+ export {
113
+ CATALOG_KEY,
114
+ CATALOG_NAMESPACE,
115
+ createCatalogStore,
116
+ memoryCatalogStore,
117
+ } from "./model/catalogStore.js";
118
+ export type {
119
+ CatalogStore,
120
+ CreateCatalogStoreOptions,
121
+ } from "./model/catalogStore.js";
122
+ export { MAX_SYNC_PAGES, syncCatalog } from "./model/catalogSync.js";
123
+ export type { SyncCatalogOptions, SyncCatalogResult } from "./model/catalogSync.js";
124
+ export { catalogKeyOptions, categoriesQueryKeys } from "./model/queryKeys.js";
125
+ export type { CatalogKeyOptions } from "./model/queryKeys.js";
126
+ export {
127
+ useCategoriesRevision,
128
+ useCategoryCarousel,
129
+ useCategoryCatalog,
130
+ useCategoryChildren,
131
+ useCategoryFeatures,
132
+ } from "./model/queries.js";
133
+ export type {
134
+ CategoryCatalog,
135
+ UseCategoryCatalogOptions,
136
+ } from "./model/queries.js";
137
+
138
+ // ── headless (renderless components) ─────────────────────────────────────────
139
+ export { CategoriesProvider } from "./headless/CategoriesProvider.js";
140
+ export { CategoryTree } from "./headless/CategoryTree.js";
141
+ export type { CategoryTreeBag, CategoryTreeProps } from "./headless/CategoryTree.js";
142
+ export { CategoryBreadcrumbs } from "./headless/CategoryBreadcrumbs.js";
143
+ export type {
144
+ CategoryBreadcrumbsBag,
145
+ CategoryBreadcrumbsProps,
146
+ CategoryCrumb,
147
+ } from "./headless/CategoryBreadcrumbs.js";
148
+ export { CategoryCarousel } from "./headless/CategoryCarousel.js";
149
+ export type {
150
+ CarouselEntry,
151
+ CategoryCarouselBag,
152
+ CategoryCarouselProps,
153
+ } from "./headless/CategoryCarousel.js";
154
+ export { CategoryPicker } from "./headless/CategoryPicker.js";
155
+ export type {
156
+ CategoryOption,
157
+ CategoryPickerBag,
158
+ CategoryPickerBlockedReason,
159
+ CategoryPickerProps,
160
+ } from "./headless/CategoryPicker.js";
161
+ export { CategoryFeatures } from "./headless/CategoryFeatures.js";
162
+ export type {
163
+ CategoryFeatureEntry,
164
+ CategoryFeaturesBag,
165
+ CategoryFeaturesProps,
166
+ } from "./headless/CategoryFeatures.js";
167
+
168
+ // ── nav manifest (the pair's public surface declaration) ─────────────────────
169
+ export { navEntries } from "./nav/manifest.js";
170
+
171
+ // ── i18n ─────────────────────────────────────────────────────────────────────
172
+ export {
173
+ CATEGORIES_I18N_KEYS,
174
+ categoriesI18nBundleEn,
175
+ registerCategoriesI18n,
176
+ } from "./i18n/keys.js";
177
+ export type { CategoriesI18nKey } from "./i18n/keys.js";
178
+
179
+ // ── errors map (code → status/params/remediation/en; generated) ──────────────
180
+ export {
181
+ CATEGORIES_ERRORS,
182
+ CATEGORIES_ERROR_CODES,
183
+ CATEGORIES_FEATURE_EDITOR_CONFLICT,
184
+ categoriesErrorBundleEn,
185
+ explainCategoriesError,
186
+ } from "./i18n/errorsMap.js";
187
+ export type {
188
+ CategoriesErrorCode,
189
+ CategoriesErrorSpec,
190
+ Remediation,
191
+ } from "./i18n/errorsMap.js";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Where the catalogue lives between page loads.
3
+ *
4
+ * `createRepository` is the ONE sanctioned client-side persistence primitive
5
+ * (`@stapel/core` `repository.ts`; direct `localStorage` outside it is a lint
6
+ * error, `stapel/no-raw-storage`), and the spec's verdict (§4.3) puts the tree
7
+ * in it with `scope: "app"`.
8
+ *
9
+ * **`scope: "app"` is a correctness choice, not a size one.** A user-scoped
10
+ * repository is encrypted with the per-session key and wiped on logout, with
11
+ * no opt-out — both of which are wrong here twice over: the category tree is
12
+ * a deployment's PUBLIC content, identical for every visitor and for a visitor
13
+ * with no session at all, and wiping it at logout would re-download the whole
14
+ * catalogue for somebody who just signed out of a storefront they are still
15
+ * browsing. App scope also means `createRepository` never asks for a
16
+ * `SessionManager`, so the store works on a page with no auth wired at all.
17
+ *
18
+ * A repository read can fail or return junk (a private window, cleared site
19
+ * data, a snapshot written by an older shape). Every read here degrades to
20
+ * "no snapshot" — a cold sync costs one request; a cache that throws costs
21
+ * the page.
22
+ */
23
+ import { createRepository } from "@stapel/core";
24
+ import type { Repository } from "@stapel/core";
25
+ import { EMPTY_SNAPSHOT, parseSnapshot } from "../catalog/sync.js";
26
+ import type { CategorySnapshot } from "../catalog/sync.js";
27
+
28
+ /** The repository namespace. Changing it orphans every stored snapshot, which
29
+ * is the intended migration for a shape change too large for `version`. */
30
+ export const CATALOG_NAMESPACE = "categories.catalog";
31
+
32
+ /** The single key inside that namespace. */
33
+ export const CATALOG_KEY = "snapshot";
34
+
35
+ /** Read / write the catalogue snapshot, with every failure mode absorbed. */
36
+ export interface CatalogStore {
37
+ load(): Promise<CategorySnapshot>;
38
+ save(snapshot: CategorySnapshot): Promise<void>;
39
+ clear(): Promise<void>;
40
+ }
41
+
42
+ export interface CreateCatalogStoreOptions {
43
+ /** Inject a repository (tests, SSR, a host with its own backend). Default:
44
+ * an app-scoped repository under {@link CATALOG_NAMESPACE}. */
45
+ readonly repository?: Repository<unknown>;
46
+ }
47
+
48
+ /** A store that remembers nothing — the honest fallback when persistence is
49
+ * unavailable, and what a caller passes to opt out of caching entirely. */
50
+ export function memoryCatalogStore(): CatalogStore {
51
+ let held: CategorySnapshot = EMPTY_SNAPSHOT;
52
+ return {
53
+ load: () => Promise.resolve(held),
54
+ save: (snapshot) => {
55
+ held = snapshot;
56
+ return Promise.resolve();
57
+ },
58
+ clear: () => {
59
+ held = EMPTY_SNAPSHOT;
60
+ return Promise.resolve();
61
+ },
62
+ };
63
+ }
64
+
65
+ export function createCatalogStore(
66
+ options: CreateCatalogStoreOptions = {}
67
+ ): CatalogStore {
68
+ let repository: Repository<unknown>;
69
+ try {
70
+ repository =
71
+ options.repository ??
72
+ createRepository<unknown>(CATALOG_NAMESPACE, { scope: "app" });
73
+ } catch {
74
+ // No storage backend at all (an exotic runtime, a locked-down origin).
75
+ return memoryCatalogStore();
76
+ }
77
+
78
+ return {
79
+ async load() {
80
+ try {
81
+ return parseSnapshot(await repository.get(CATALOG_KEY)) ?? EMPTY_SNAPSHOT;
82
+ } catch {
83
+ return EMPTY_SNAPSHOT;
84
+ }
85
+ },
86
+ async save(snapshot) {
87
+ try {
88
+ await repository.set(CATALOG_KEY, snapshot);
89
+ } catch {
90
+ // A full quota or a blocked origin must not fail the render that
91
+ // triggered the sync: the catalogue is already in memory and correct.
92
+ }
93
+ },
94
+ async clear() {
95
+ try {
96
+ await repository.del(CATALOG_KEY);
97
+ } catch {
98
+ /* nothing to undo */
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The sync walk: stored snapshot → one request or several → a fresh snapshot.
3
+ *
4
+ * Pure of React, so the same routine serves the hook, an SSR prefetch and a
5
+ * test. The rules it enforces are all in `catalog/sync.ts`; this is the loop
6
+ * that applies them, plus the two guards a loop over a paginated endpoint
7
+ * needs:
8
+ *
9
+ * - a **page budget**, because `has_next` is server-reported and a bug there
10
+ * (or a catalogue growing faster than the walk reads it) is an infinite
11
+ * request loop against someone's production API, not a slow render; and
12
+ * - **publish-on-completion**, so a walk that dies on page 3 leaves the
13
+ * previous catalogue intact instead of replacing it with two thirds of one.
14
+ */
15
+ import type { CategoriesApi } from "../api/categoriesApi.js";
16
+ import type { CategoryListParams } from "../api/types.js";
17
+ import {
18
+ EMPTY_SNAPSHOT,
19
+ applyCategoryPage,
20
+ firstPageRequest,
21
+ isEmptySnapshot,
22
+ nextPageRequest,
23
+ } from "../catalog/sync.js";
24
+ import type { CategorySnapshot } from "../catalog/sync.js";
25
+
26
+ /**
27
+ * Hard ceiling on pages per sync. At the server's max `page_size` of 1000 this
28
+ * is a million categories — far beyond any real catalogue, and small enough
29
+ * that a runaway loop stops in seconds instead of hammering a backend.
30
+ */
31
+ export const MAX_SYNC_PAGES = 1000;
32
+
33
+ export interface SyncCatalogOptions {
34
+ readonly signal?: AbortSignal;
35
+ /** Rows per request. Server default 100, maximum 1000. */
36
+ readonly pageSize?: number;
37
+ readonly maxPages?: number;
38
+ }
39
+
40
+ export interface SyncCatalogResult {
41
+ readonly snapshot: CategorySnapshot;
42
+ /** `true` when the walk started from nothing (a cold catalogue). */
43
+ readonly wasFullSync: boolean;
44
+ /** How many requests the walk actually made. `0` is impossible — the
45
+ * freshness probe is the caller's job, not this function's. */
46
+ readonly pages: number;
47
+ /** The page budget stopped the walk before `has_next` did. The snapshot is
48
+ * a truncated catalogue and the caller must NOT treat it as complete. */
49
+ readonly truncated: boolean;
50
+ }
51
+
52
+ /**
53
+ * Bring `stored` up to date.
54
+ *
55
+ * A cold start walks the whole catalogue into a fresh accumulator; a warm
56
+ * start folds the delta into the stored rows. Which one happens is decided by
57
+ * `firstPageRequest` and by what the accumulator starts as — there is no flag
58
+ * that a caller could set the wrong way (see `catalog/sync.ts`).
59
+ */
60
+ export async function syncCatalog(
61
+ api: CategoriesApi,
62
+ stored: CategorySnapshot,
63
+ options: SyncCatalogOptions = {}
64
+ ): Promise<SyncCatalogResult> {
65
+ const wasFullSync = isEmptySnapshot(stored);
66
+ const budget = options.maxPages ?? MAX_SYNC_PAGES;
67
+
68
+ let request: CategoryListParams | undefined = {
69
+ ...firstPageRequest(stored),
70
+ ...(options.pageSize !== undefined ? { pageSize: options.pageSize } : {}),
71
+ };
72
+ // A full sync must not inherit rows the server is no longer listing, so it
73
+ // accumulates from empty. A delta folds into what is already known.
74
+ let accumulator: CategorySnapshot = wasFullSync
75
+ ? EMPTY_SNAPSHOT
76
+ : { ...stored };
77
+ let pages = 0;
78
+ let truncated = false;
79
+
80
+ while (request !== undefined) {
81
+ if (pages >= budget) {
82
+ truncated = true;
83
+ break;
84
+ }
85
+ const current: CategoryListParams = request;
86
+ const page = await api.list(current, {
87
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
88
+ });
89
+ pages += 1;
90
+ accumulator = applyCategoryPage(accumulator, page, {
91
+ // The walk pinned its window with `max_revision`; the cursor may not
92
+ // claim to have read past it.
93
+ ...(current.maxRevision !== undefined
94
+ ? { cursorLimit: current.maxRevision }
95
+ : {}),
96
+ });
97
+ request = nextPageRequest(current, page);
98
+ }
99
+
100
+ if (truncated) {
101
+ // The rows gathered so far are still worth showing — a partial catalogue
102
+ // beats a blank menu — but the CURSOR must not advance, or the next sync
103
+ // asks for a delta on top of a catalogue that was never fully read and the
104
+ // gap becomes permanent. Rewinding it here means the worst case is one
105
+ // repeated full walk, not a silently incomplete tree forever.
106
+ return {
107
+ snapshot: { ...accumulator, cursor: stored.cursor },
108
+ wasFullSync,
109
+ pages,
110
+ truncated,
111
+ };
112
+ }
113
+
114
+ return { snapshot: accumulator, wasFullSync, pages, truncated };
115
+ }