@stapel/vocabularies-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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +92 -0
  4. package/README.md +173 -0
  5. package/dist/api/extensions.d.ts +8 -0
  6. package/dist/api/extensions.d.ts.map +1 -0
  7. package/dist/api/extensions.js +8 -0
  8. package/dist/api/extensions.js.map +1 -0
  9. package/dist/api/generated/schema.d.ts +234 -0
  10. package/dist/api/generated/schema.d.ts.map +1 -0
  11. package/dist/api/generated/schema.js +2 -0
  12. package/dist/api/generated/schema.js.map +1 -0
  13. package/dist/api/types.d.ts +23 -0
  14. package/dist/api/types.d.ts.map +1 -0
  15. package/dist/api/types.js +2 -0
  16. package/dist/api/types.js.map +1 -0
  17. package/dist/api/vocabulariesApi.d.ts +14 -0
  18. package/dist/api/vocabulariesApi.d.ts.map +1 -0
  19. package/dist/api/vocabulariesApi.js +4 -0
  20. package/dist/api/vocabulariesApi.js.map +1 -0
  21. package/dist/client.d.ts +49 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +118 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/default/VocabularyTermSelect.d.ts +24 -0
  26. package/dist/default/VocabularyTermSelect.d.ts.map +1 -0
  27. package/dist/default/VocabularyTermSelect.js +75 -0
  28. package/dist/default/VocabularyTermSelect.js.map +1 -0
  29. package/dist/default/index.d.ts +16 -0
  30. package/dist/default/index.d.ts.map +1 -0
  31. package/dist/default/index.js +14 -0
  32. package/dist/default/index.js.map +1 -0
  33. package/dist/default/types.d.ts +12 -0
  34. package/dist/default/types.d.ts.map +1 -0
  35. package/dist/default/types.js +2 -0
  36. package/dist/default/types.js.map +1 -0
  37. package/dist/flows/errors.d.ts +12 -0
  38. package/dist/flows/errors.d.ts.map +1 -0
  39. package/dist/flows/errors.js +13 -0
  40. package/dist/flows/errors.js.map +1 -0
  41. package/dist/flows/registry.d.ts +22 -0
  42. package/dist/flows/registry.d.ts.map +1 -0
  43. package/dist/flows/registry.js +20 -0
  44. package/dist/flows/registry.js.map +1 -0
  45. package/dist/headless/VocabulariesProvider.d.ts +19 -0
  46. package/dist/headless/VocabulariesProvider.d.ts.map +1 -0
  47. package/dist/headless/VocabulariesProvider.js +15 -0
  48. package/dist/headless/VocabulariesProvider.js.map +1 -0
  49. package/dist/i18n/errorsMap.d.ts +10 -0
  50. package/dist/i18n/errorsMap.d.ts.map +1 -0
  51. package/dist/i18n/errorsMap.js +20 -0
  52. package/dist/i18n/errorsMap.js.map +1 -0
  53. package/dist/i18n/es.d.ts +21 -0
  54. package/dist/i18n/es.d.ts.map +1 -0
  55. package/dist/i18n/es.js +38 -0
  56. package/dist/i18n/es.js.map +1 -0
  57. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  58. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  59. package/dist/i18n/generated/errors.es.gen.js +58 -0
  60. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  61. package/dist/i18n/generated/errors.gen.d.ts +305 -0
  62. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  63. package/dist/i18n/generated/errors.gen.js +156 -0
  64. package/dist/i18n/generated/errors.gen.js.map +1 -0
  65. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  66. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  67. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  68. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  69. package/dist/i18n/keys.d.ts +33 -0
  70. package/dist/i18n/keys.d.ts.map +1 -0
  71. package/dist/i18n/keys.js +44 -0
  72. package/dist/i18n/keys.js.map +1 -0
  73. package/dist/i18n/ru.d.ts +26 -0
  74. package/dist/i18n/ru.d.ts.map +1 -0
  75. package/dist/i18n/ru.js +43 -0
  76. package/dist/i18n/ru.js.map +1 -0
  77. package/dist/index.d.ts +34 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +38 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/model/context.d.ts +11 -0
  82. package/dist/model/context.d.ts.map +1 -0
  83. package/dist/model/context.js +16 -0
  84. package/dist/model/context.js.map +1 -0
  85. package/dist/model/queryKeys.d.ts +5 -0
  86. package/dist/model/queryKeys.d.ts.map +1 -0
  87. package/dist/model/queryKeys.js +28 -0
  88. package/dist/model/queryKeys.js.map +1 -0
  89. package/dist/model/runtime.d.ts +17 -0
  90. package/dist/model/runtime.d.ts.map +1 -0
  91. package/dist/model/runtime.js +6 -0
  92. package/dist/model/runtime.js.map +1 -0
  93. package/dist/model/useTermLabels.d.ts +26 -0
  94. package/dist/model/useTermLabels.d.ts.map +1 -0
  95. package/dist/model/useTermLabels.js +58 -0
  96. package/dist/model/useTermLabels.js.map +1 -0
  97. package/dist/model/useTermSearch.d.ts +24 -0
  98. package/dist/model/useTermSearch.d.ts.map +1 -0
  99. package/dist/model/useTermSearch.js +85 -0
  100. package/dist/model/useTermSearch.js.map +1 -0
  101. package/llms.txt +79 -0
  102. package/manifest.json +466 -0
  103. package/package.json +108 -0
  104. package/src/analytics/generated/events.json +7 -0
  105. package/src/api/extensions.ts +7 -0
  106. package/src/api/generated/schema.ts +236 -0
  107. package/src/api/types.ts +27 -0
  108. package/src/api/vocabulariesApi.ts +17 -0
  109. package/src/client.ts +176 -0
  110. package/src/default/VocabularyTermSelect.tsx +147 -0
  111. package/src/default/index.ts +15 -0
  112. package/src/default/types.ts +11 -0
  113. package/src/flows/errors.ts +16 -0
  114. package/src/flows/registry.ts +32 -0
  115. package/src/headless/VocabulariesProvider.tsx +20 -0
  116. package/src/i18n/errorsMap.ts +31 -0
  117. package/src/i18n/es.ts +49 -0
  118. package/src/i18n/generated/errors.es.gen.ts +64 -0
  119. package/src/i18n/generated/errors.gen.ts +187 -0
  120. package/src/i18n/generated/errors.json +351 -0
  121. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  122. package/src/i18n/keys.ts +53 -0
  123. package/src/i18n/ru.ts +54 -0
  124. package/src/index.ts +92 -0
  125. package/src/model/context.tsx +28 -0
  126. package/src/model/queryKeys.ts +35 -0
  127. package/src/model/runtime.ts +25 -0
  128. package/src/model/useTermLabels.ts +72 -0
  129. package/src/model/useTermSearch.ts +118 -0
  130. package/tsconfig.json +26 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `<VocabularyTermSelect/>` — one vocabulary level as a typeahead.
3
+ *
4
+ * The SAME control `@stapel/attributes-react`'s `ref_select` editor draws,
5
+ * shipped on its own so the places a composer is not — a facet filter, an
6
+ * admin form, a bulk-edit row — do not each grow their own. The two are
7
+ * deliberately separate components rather than one imported across the seam:
8
+ * the editor's props are the attributes value-editor contract (a `FeatureDef`,
9
+ * `siblings`, a rule-narrowed config) and this one's are a vocabulary pointer,
10
+ * and collapsing them would make the two L2 pairs depend on each other for a
11
+ * hundred lines of antd.
12
+ *
13
+ * What it owes a person, and where each part lives:
14
+ *
15
+ * - the options ARE the answer to the current query, so `filterOption={false}`
16
+ * — letting antd filter them again would hide rows the server deliberately
17
+ * ranked (a prefix match first, a label matched in another language);
18
+ * - typing is debounced and superseding (`useTermSearch`);
19
+ * - a code the control already HOLDS is resolved to its label
20
+ * (`useTermLabels`) and kept in the option list even when the current page
21
+ * does not contain it — otherwise reopening a saved filter would silently
22
+ * empty the control;
23
+ * - no client is a LOUD state, not an empty dropdown: a control that cannot
24
+ * reach its terms and looks like one that found none is how a person ends
25
+ * up unable to answer a question nobody told them was broken.
26
+ */
27
+ import { useMemo } from "react";
28
+ import type { ReactElement } from "react";
29
+ import { Alert, Select } from "antd";
30
+ import { useT } from "@stapel/core";
31
+ import { SkinTheme } from "@stapel/tokens-antd/skin";
32
+ import type { VocabularyClient } from "../client.js";
33
+ import { useTermSearch } from "../model/useTermSearch.js";
34
+ import { useTermLabels, termLabel } from "../model/useTermLabels.js";
35
+ import { VOCABULARIES_I18N_KEYS } from "../i18n/keys.js";
36
+ import type { ThemeModeProp } from "./types.js";
37
+
38
+ export interface VocabularyTermSelectProps extends ThemeModeProp {
39
+ /** The seam. `null` draws the unavailable notice — see the module header. */
40
+ readonly client: VocabularyClient | null;
41
+ readonly vocabulary: string;
42
+ readonly level: string;
43
+ /** Code of a term at the level above; narrows the list to its children. */
44
+ readonly parent?: string | undefined;
45
+ /** The codes currently chosen. Always a list, even single-select — a term
46
+ * value is a list on the wire. */
47
+ readonly value?: readonly string[] | undefined;
48
+ readonly onChange?: ((codes: readonly string[]) => void) | undefined;
49
+ /** Several terms at once (a facet filter); default is one. */
50
+ readonly multiple?: boolean | undefined;
51
+ /** Upper bound for a multiple select. */
52
+ readonly maxCount?: number | undefined;
53
+ readonly disabled?: boolean | undefined;
54
+ readonly id?: string | undefined;
55
+ readonly status?: "error" | "warning" | undefined;
56
+ }
57
+
58
+ export function VocabularyTermSelect(
59
+ props: VocabularyTermSelectProps
60
+ ): ReactElement {
61
+ const t = useT();
62
+ const { client, vocabulary, level, parent } = props;
63
+ const codes = useMemo(
64
+ () => (props.value ?? []).filter((code) => code.length > 0),
65
+ [props.value]
66
+ );
67
+
68
+ const { terms, loading, search, open } = useTermSearch(client, {
69
+ vocabulary,
70
+ level,
71
+ parent,
72
+ });
73
+ const labels = useTermLabels(client, { vocabulary, level, codes });
74
+
75
+ // A held code the current page does not contain still has to be visible and
76
+ // pickable, so it is prepended rather than looked up in `terms`.
77
+ const options = useMemo(
78
+ () => [
79
+ ...codes
80
+ .filter((code) => !terms.some((term) => term.code === code))
81
+ .map((code) => ({ value: code, label: termLabel(labels, code) })),
82
+ ...terms.map((term) => ({ value: term.code, label: term.label })),
83
+ ],
84
+ [codes, terms, labels]
85
+ );
86
+
87
+ if (client === null || vocabulary.length === 0 || level.length === 0) {
88
+ return (
89
+ <SkinTheme
90
+ surface="bare"
91
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
92
+ >
93
+ <Alert
94
+ type="warning"
95
+ showIcon
96
+ data-testid="vocabulary-term-select-unavailable"
97
+ title={t(VOCABULARIES_I18N_KEYS.termSelectUnavailableTitle)}
98
+ description={t(VOCABULARIES_I18N_KEYS.termSelectUnavailable)}
99
+ />
100
+ </SkinTheme>
101
+ );
102
+ }
103
+
104
+ const multiple = props.multiple === true;
105
+ const onChange = props.onChange;
106
+
107
+ return (
108
+ <SkinTheme
109
+ surface="bare"
110
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
111
+ >
112
+ <Select
113
+ {...(props.id !== undefined ? { id: props.id } : {})}
114
+ data-testid="vocabulary-term-select"
115
+ style={{ width: "100%" }}
116
+ showSearch
117
+ filterOption={false}
118
+ options={options}
119
+ loading={loading}
120
+ disabled={props.disabled === true}
121
+ {...(props.status !== undefined ? { status: props.status } : {})}
122
+ {...(multiple ? { mode: "multiple" as const } : {})}
123
+ {...(multiple && props.maxCount !== undefined
124
+ ? { maxCount: props.maxCount }
125
+ : {})}
126
+ placeholder={t(VOCABULARIES_I18N_KEYS.termSelectPlaceholder)}
127
+ // `null` while loading, so antd draws its own spinner instead of
128
+ // saying "no matches" about a question that has not been answered yet.
129
+ notFoundContent={loading ? null : t(VOCABULARIES_I18N_KEYS.termSelectNoMatches)}
130
+ value={multiple ? codes : (codes[0] ?? null)}
131
+ onDropdownVisibleChange={(visible: boolean) => {
132
+ if (visible) open();
133
+ }}
134
+ onSearch={search}
135
+ onChange={(next: string | readonly string[] | null) => {
136
+ const picked =
137
+ next === null || next === undefined
138
+ ? []
139
+ : Array.isArray(next)
140
+ ? [...(next as readonly string[])]
141
+ : [next as string];
142
+ onChange?.(picked.filter((code) => code.length > 0));
143
+ }}
144
+ />
145
+ </SkinTheme>
146
+ );
147
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@stapel/vocabularies-react/default` — the pair's default AntD skin (§54: a pair ships a
3
+ * FEATURE, not only a bag). A separate entry point, so a host that brings its
4
+ * own visuals never pulls `antd` or the token bridge into its bundle;
5
+ * importing this subpath is the opt-in.
6
+ *
7
+ * ```tsx
8
+ * import { VocabularyTermSelect } from "@stapel/vocabularies-react/default";
9
+ * // under core's <I18nProvider>; the client is a prop, not a context read:
10
+ * <VocabularyTermSelect client={client} vocabulary="avito-phones" level="Vendor" … />
11
+ * ```
12
+ */
13
+ export { VocabularyTermSelect } from "./VocabularyTermSelect.js";
14
+ export type { VocabularyTermSelectProps } from "./VocabularyTermSelect.js";
15
+ export type { ThemeModeProp } from "./types.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The one prop every skin component in this package shares.
3
+ *
4
+ * `mode` is OPTIONAL and has no default here on purpose: the shared
5
+ * `SkinTheme` (`@stapel/tokens-antd/skin`) follows the host's theme when
6
+ * nobody overrides it, and a `mode = "light"` default in a pair is how a dark
7
+ * host ends up with one white rectangle in the middle of its app.
8
+ */
9
+ export interface ThemeModeProp {
10
+ readonly mode?: "light" | "dark";
11
+ }
@@ -0,0 +1,16 @@
1
+ import { toFlowError as coreToFlowError } from "@stapel/core";
2
+ import type { FlowError } from "@stapel/core";
3
+
4
+ export type { FlowError } from "@stapel/core";
5
+ export { isErrorCode } from "@stapel/core";
6
+
7
+ /**
8
+ * Fold any thrown value into a {@link FlowError} using this pair's own
9
+ * module-scoped fallback key (`vocabularies.error.unknown`, an en string in
10
+ * {@link vocabulariesI18nBundleEn}) so a non-`StapelApiError` fault still renders
11
+ * real copy rather than a raw key. The primitive lives in `@stapel/core`
12
+ * (frontend-core-architecture §4b); this wrapper only pins the fallback.
13
+ */
14
+ export function toFlowError(error: unknown): FlowError {
15
+ return coreToFlowError(error, "vocabularies.error.unknown");
16
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Zero-flow registry shim (slim wave §21/S3). stapel-vocabularies annotates no
3
+ * `@flow_step` yet — its backend `docs/flows.json` carries no `vocabularies.*`
4
+ * flows, so `gen:flows` skips emission for this pair (no `flows/generated/`
5
+ * scaffolding). This hand-written shim preserves the pair's public
6
+ * flow-registry surface at its zero-flow shape.
7
+ *
8
+ * When the backend annotates flows, `pnpm gen:flows` emits
9
+ * `./generated/flows.gen.ts` again — replace these exports with re-exports
10
+ * from it (the shapes match by construction) and delete this file.
11
+ */
12
+ export const VOCABULARIES_FLOWS = {} as const;
13
+
14
+ /** Canonical flow ids present in flows.json (none yet — see above). */
15
+ export type VocabulariesFlowId = keyof typeof VOCABULARIES_FLOWS;
16
+
17
+ export type VocabulariesFlowSpec = (typeof VOCABULARIES_FLOWS)[VocabulariesFlowId];
18
+
19
+ export interface FlowEndpoint {
20
+ readonly method: string;
21
+ readonly path: string;
22
+ }
23
+
24
+ /** All HTTP endpoints a flow touches, in step order (for the contract test / MSW). */
25
+ export function flowEndpoints(id: VocabulariesFlowId): readonly FlowEndpoint[] {
26
+ // Same widened body as the generated registry's — valid for the zero-flow
27
+ // shape AND correct once flows exist.
28
+ const spec = VOCABULARIES_FLOWS[id] as
29
+ | { readonly steps: readonly { readonly endpoints: readonly FlowEndpoint[] }[] }
30
+ | undefined;
31
+ return spec ? spec.steps.flatMap((s) => s.endpoints) : [];
32
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { ModuleProvider } from "../model/context.js";
3
+ import type { VocabulariesRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Provides the wired {@link VocabulariesRuntime} to every vocabularies 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
9
+ * to this pair — slim wave §21/S2.)
10
+ *
11
+ * ```tsx
12
+ * const runtime = createVocabulariesRuntime({ baseUrl: "/vocabularies/api/v1/" });
13
+ * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
14
+ * <VocabulariesProvider runtime={runtime}>{app}</VocabulariesProvider>
15
+ * ```
16
+ */
17
+ export const VocabulariesProvider: (props: {
18
+ runtime: VocabulariesRuntime;
19
+ children: ReactNode;
20
+ }) => ReactElement = ModuleProvider;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7, frontend-core §2.5):
3
+ * the generated `code → { status, params, remediation, en }` catalog plus a
4
+ * tiny `explain()` lookup. Backs the manifest `errors` block and gives hosts a
5
+ * mechanical UX branch beside `t(code, params)`. The map itself is generated
6
+ * from the backend registry (`pnpm gen:errors`); this file only adds the lookup
7
+ * helper and re-exports the public surface.
8
+ */
9
+ import { VOCABULARIES_ERRORS } from "./generated/errors.gen.js";
10
+ import type { Remediation } from "./generated/errors.gen.js";
11
+
12
+ export {
13
+ VOCABULARIES_ERRORS,
14
+ VOCABULARIES_ERROR_CODES,
15
+ vocabulariesErrorBundleEn,
16
+ } from "./generated/errors.gen.js";
17
+ export type {
18
+ VocabulariesErrorCode,
19
+ VocabulariesErrorSpec,
20
+ Remediation,
21
+ } from "./generated/errors.gen.js";
22
+
23
+ /**
24
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
25
+ * code this module doesn't know (e.g. a cross-cutting `stapel.http.*` fallback).
26
+ * Zero guessing at runtime — a static lookup over the generated map.
27
+ */
28
+ export function explainVocabulariesError(code: string): Remediation | undefined {
29
+ return (VOCABULARIES_ERRORS as Record<string, { remediation: Remediation }>)[code]
30
+ ?.remediation;
31
+ }
package/src/i18n/es.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { vocabulariesErrorBundleEs } from "./generated/errors.es.gen.js";
3
+ import { vocabulariesI18nBundleEn } from "./keys.js";
4
+
5
+ export { vocabulariesErrorBundleEs } from "./generated/errors.es.gen.js";
6
+
7
+ /**
8
+ * Spanish bundle for vocabularies-react — shipped as the
9
+ * `@stapel/vocabularies-react/i18n/es` subpath (i18n-shipping.md §2) so the locale is
10
+ * opt-in: a host that never registers it never carries these strings (the main
11
+ * entry does not import this module).
12
+ *
13
+ * TWO SOURCES, ON PURPOSE — see the ru bundle's header: the generated
14
+ * `vocabulariesErrorBundleEs` covers the codes stapel-core owns, and the 3
15
+ * stapel-vocabularies owns are authored below until upstream ships a
16
+ * `translations/errors.es.json`.
17
+ */
18
+ export const vocabulariesI18nBundleEs: I18nDictionary = {
19
+ // Backend error codes — generated es texts for every key core owns.
20
+ ...vocabulariesErrorBundleEs,
21
+
22
+ // Backend error codes stapel-vocabularies owns — authored here (see above).
23
+ "error.400.vocabularies_bad_parent":
24
+ "No hay ningún término «{parent}» en el nivel superior de «{level}»",
25
+ "error.404.vocabularies_level_not_found":
26
+ "El vocabulario «{vocabulary}» no tiene el nivel «{level}»",
27
+ "error.404.vocabularies_vocabulary_not_found": "Vocabulario no encontrado",
28
+
29
+ // vocabularies-react UI
30
+ "vocabularies.error.unknown": "Algo salió mal. Inténtalo de nuevo.",
31
+ "vocabularies.termSelect.placeholder": "Escribe para buscar…",
32
+ "vocabularies.termSelect.noMatches": "Ningún término coincide.",
33
+ "vocabularies.termSelect.unavailable.title": "No se puede cargar esta lista",
34
+ "vocabularies.termSelect.unavailable":
35
+ "Los valores de este campo vienen de un servicio de vocabularios que no está configurado aquí, así que no hay nada que elegir. Nadie puede responder a esta pregunta hasta que se conecte — avísanos, por favor.",
36
+ };
37
+
38
+ /**
39
+ * Register the Spanish bundle. The en bundle goes UNDER it
40
+ * (merge-priority convention): a key this locale has not translated yet
41
+ * degrades to ENGLISH, never to a raw key.
42
+ */
43
+ export function registerVocabulariesI18nEs(
44
+ engine: I18nEngine,
45
+ locale = "es"
46
+ ): void {
47
+ engine.registerBundle(locale, vocabulariesI18nBundleEn);
48
+ engine.registerBundle(locale, vocabulariesI18nBundleEs);
49
+ }
@@ -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 { VocabulariesErrorCode } 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: 3 key(s) owned by
12
+ * stapel_vocabularies 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 vocabulariesErrorBundleEs: Partial<Record<VocabulariesErrorCode, 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
+ };
@@ -0,0 +1,187 @@
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
+
5
+ /**
6
+ * Remediation vocabulary (frontend-core-architecture §2.5). Declared by the
7
+ * backend on the error registry and consumed verbatim from its `errors.json`
8
+ * artifact. A host maps it to UX — retryable → "try again"; `wait_and_retry` →
9
+ * timer from `params.retry_after_minutes`; `verify` → the step-up seam;
10
+ * `fix_input` → highlight the offending field.
11
+ */
12
+ export type Remediation =
13
+ | "retry"
14
+ | "wait_and_retry"
15
+ | "reauthenticate"
16
+ | "verify"
17
+ | "fix_input"
18
+ | "contact_support"
19
+ | "bug";
20
+
21
+ export interface VocabulariesErrorSpec {
22
+ /** HTTP status the backend raises this key with. */
23
+ readonly status: number;
24
+ /** `{param}` interpolation slots present in the message. */
25
+ readonly params: readonly string[];
26
+ /** Remediation hint declared by the backend (see {@link Remediation}). */
27
+ readonly remediation: Remediation;
28
+ /** English fallback (source: stapel-auth errors.json). */
29
+ readonly en: string;
30
+ }
31
+
32
+ /**
33
+ * The VOCABULARIES_ERRORS map — every `error.*` key stapel-auth can raise, keyed by its
34
+ * canonical code. The single source of truth behind the en fallback bundle, the
35
+ * remediation lookup, and the manifest `errors` block.
36
+ */
37
+ export const VOCABULARIES_ERRORS = {
38
+ "error.400.bad_request": { status: 400, params: [], remediation: "fix_input", en: "Bad request" },
39
+ "error.400.captcha_invalid": { status: 400, params: [], remediation: "retry", en: "Captcha verification failed. Please try again." },
40
+ "error.400.captcha_required": { status: 400, params: [], remediation: "retry", en: "Captcha token is required." },
41
+ "error.400.expected_list": { status: 400, params: [], remediation: "fix_input", en: "Expected a list of items" },
42
+ "error.400.field.blank": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} may not be blank" },
43
+ "error.400.field.does_not_exist": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} does not exist" },
44
+ "error.400.field.invalid": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} is invalid" },
45
+ "error.400.field.invalid_choice": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} is not a valid choice" },
46
+ "error.400.field.max_length": { status: 400, params: ["field","max_length"], remediation: "fix_input", en: "{field} must be at most {max_length} characters" },
47
+ "error.400.field.max_value": { status: 400, params: ["field","max_value"], remediation: "fix_input", en: "{field} must be at most {max_value}" },
48
+ "error.400.field.min_length": { status: 400, params: ["field","min_length"], remediation: "fix_input", en: "{field} must be at least {min_length} characters" },
49
+ "error.400.field.min_value": { status: 400, params: ["field","min_value"], remediation: "fix_input", en: "{field} must be at least {min_value}" },
50
+ "error.400.field.null": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} may not be null" },
51
+ "error.400.field.required": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} is required" },
52
+ "error.400.field.unique": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} must be unique" },
53
+ "error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
54
+ "error.400.validation_error": { status: 400, params: [], remediation: "fix_input", en: "Validation error" },
55
+ "error.400.verification_failed": { status: 400, params: [], remediation: "verify", en: "Verification failed" },
56
+ "error.400.verification_invalid_factor": { status: 400, params: [], remediation: "verify", en: "This verification factor is not available" },
57
+ "error.400.vocabularies_bad_parent": { status: 400, params: ["parent","level"], remediation: "fix_input", en: "No term '{parent}' at the parent level of '{level}'" },
58
+ "error.401.unauthorized": { status: 401, params: [], remediation: "reauthenticate", en: "Authentication required" },
59
+ "error.402.payment_required": { status: 402, params: [], remediation: "retry", en: "Payment required" },
60
+ "error.403.forbidden": { status: 403, params: [], remediation: "retry", en: "You do not have permission to perform this action" },
61
+ "error.403.network_blocked": { status: 403, params: [], remediation: "contact_support", en: "Requests from this network are not allowed" },
62
+ "error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
63
+ "error.403.verification_required": { status: 403, params: [], remediation: "verify", en: "Additional verification required" },
64
+ "error.404.ad_not_found": { status: 404, params: [], remediation: "retry", en: "Listing not found" },
65
+ "error.404.not_found": { status: 404, params: [], remediation: "retry", en: "Requested resource not found" },
66
+ "error.404.verification_challenge_not_found": { status: 404, params: [], remediation: "verify", en: "Verification challenge not found or expired" },
67
+ "error.404.vocabularies_level_not_found": { status: 404, params: ["vocabulary","level"], remediation: "retry", en: "Vocabulary '{vocabulary}' has no level '{level}'" },
68
+ "error.404.vocabularies_vocabulary_not_found": { status: 404, params: [], remediation: "retry", en: "Vocabulary not found" },
69
+ "error.405.method_not_allowed": { status: 405, params: [], remediation: "retry", en: "Method not allowed" },
70
+ "error.406.not_acceptable": { status: 406, params: [], remediation: "retry", en: "Not acceptable" },
71
+ "error.408.request_timeout": { status: 408, params: [], remediation: "retry", en: "Request timeout" },
72
+ "error.409.conflict": { status: 409, params: [], remediation: "fix_input", en: "Resource already exists" },
73
+ "error.410.gone": { status: 410, params: [], remediation: "retry", en: "Resource has been permanently removed" },
74
+ "error.413.payload_too_large": { status: 413, params: [], remediation: "retry", en: "Request body is too large" },
75
+ "error.415.unsupported_media_type": { status: 415, params: [], remediation: "retry", en: "Unsupported media type" },
76
+ "error.422.unprocessable_entity": { status: 422, params: [], remediation: "wait_and_retry", en: "Unprocessable entity" },
77
+ "error.423.locked": { status: 423, params: [], remediation: "wait_and_retry", en: "Resource is locked" },
78
+ "error.423.verification_locked": { status: 423, params: [], remediation: "wait_and_retry", en: "Too many failed attempts — verification locked" },
79
+ "error.429.rate_limit": { status: 429, params: ["retry_after_minutes"], remediation: "wait_and_retry", en: "Too many attempts. Try again in {retry_after_minutes} minutes." },
80
+ "error.429.too_many_requests": { status: 429, params: [], remediation: "wait_and_retry", en: "Too many requests. Please try again later." },
81
+ "error.500.internal": { status: 500, params: [], remediation: "contact_support", en: "Something went wrong" },
82
+ "error.503.mandate_unavailable": { status: 503, params: [], remediation: "retry", en: "Cannot verify workspace mandate right now" },
83
+ } as const;
84
+
85
+ export type VocabulariesErrorCode = keyof typeof VOCABULARIES_ERRORS;
86
+
87
+ /** Every backend error code this module can surface, sorted. */
88
+ export const VOCABULARIES_ERROR_CODES: readonly VocabulariesErrorCode[] = [
89
+ "error.400.bad_request",
90
+ "error.400.captcha_invalid",
91
+ "error.400.captcha_required",
92
+ "error.400.expected_list",
93
+ "error.400.field.blank",
94
+ "error.400.field.does_not_exist",
95
+ "error.400.field.invalid",
96
+ "error.400.field.invalid_choice",
97
+ "error.400.field.max_length",
98
+ "error.400.field.max_value",
99
+ "error.400.field.min_length",
100
+ "error.400.field.min_value",
101
+ "error.400.field.null",
102
+ "error.400.field.required",
103
+ "error.400.field.unique",
104
+ "error.400.invalid_ad_id",
105
+ "error.400.validation_error",
106
+ "error.400.verification_failed",
107
+ "error.400.verification_invalid_factor",
108
+ "error.400.vocabularies_bad_parent",
109
+ "error.401.unauthorized",
110
+ "error.402.payment_required",
111
+ "error.403.forbidden",
112
+ "error.403.network_blocked",
113
+ "error.403.verification_enrollment_required",
114
+ "error.403.verification_required",
115
+ "error.404.ad_not_found",
116
+ "error.404.not_found",
117
+ "error.404.verification_challenge_not_found",
118
+ "error.404.vocabularies_level_not_found",
119
+ "error.404.vocabularies_vocabulary_not_found",
120
+ "error.405.method_not_allowed",
121
+ "error.406.not_acceptable",
122
+ "error.408.request_timeout",
123
+ "error.409.conflict",
124
+ "error.410.gone",
125
+ "error.413.payload_too_large",
126
+ "error.415.unsupported_media_type",
127
+ "error.422.unprocessable_entity",
128
+ "error.423.locked",
129
+ "error.423.verification_locked",
130
+ "error.429.rate_limit",
131
+ "error.429.too_many_requests",
132
+ "error.500.internal",
133
+ "error.503.mandate_unavailable",
134
+ ];
135
+
136
+ /**
137
+ * English fallback bundle — one string per backend key, so a `StapelApiError.code`
138
+ * never renders as a raw key. Merged UNDER the hand-polished UI copy in
139
+ * {@link authI18nBundleEn} (polish wins; this guarantees coverage).
140
+ */
141
+ export const vocabulariesErrorBundleEn: Record<VocabulariesErrorCode, string> = {
142
+ "error.400.bad_request": "Bad request",
143
+ "error.400.captcha_invalid": "Captcha verification failed. Please try again.",
144
+ "error.400.captcha_required": "Captcha token is required.",
145
+ "error.400.expected_list": "Expected a list of items",
146
+ "error.400.field.blank": "{field} may not be blank",
147
+ "error.400.field.does_not_exist": "{field} does not exist",
148
+ "error.400.field.invalid": "{field} is invalid",
149
+ "error.400.field.invalid_choice": "{field} is not a valid choice",
150
+ "error.400.field.max_length": "{field} must be at most {max_length} characters",
151
+ "error.400.field.max_value": "{field} must be at most {max_value}",
152
+ "error.400.field.min_length": "{field} must be at least {min_length} characters",
153
+ "error.400.field.min_value": "{field} must be at least {min_value}",
154
+ "error.400.field.null": "{field} may not be null",
155
+ "error.400.field.required": "{field} is required",
156
+ "error.400.field.unique": "{field} must be unique",
157
+ "error.400.invalid_ad_id": "Invalid advertisement ID",
158
+ "error.400.validation_error": "Validation error",
159
+ "error.400.verification_failed": "Verification failed",
160
+ "error.400.verification_invalid_factor": "This verification factor is not available",
161
+ "error.400.vocabularies_bad_parent": "No term '{parent}' at the parent level of '{level}'",
162
+ "error.401.unauthorized": "Authentication required",
163
+ "error.402.payment_required": "Payment required",
164
+ "error.403.forbidden": "You do not have permission to perform this action",
165
+ "error.403.network_blocked": "Requests from this network are not allowed",
166
+ "error.403.verification_enrollment_required": "Verification factor enrollment required",
167
+ "error.403.verification_required": "Additional verification required",
168
+ "error.404.ad_not_found": "Listing not found",
169
+ "error.404.not_found": "Requested resource not found",
170
+ "error.404.verification_challenge_not_found": "Verification challenge not found or expired",
171
+ "error.404.vocabularies_level_not_found": "Vocabulary '{vocabulary}' has no level '{level}'",
172
+ "error.404.vocabularies_vocabulary_not_found": "Vocabulary not found",
173
+ "error.405.method_not_allowed": "Method not allowed",
174
+ "error.406.not_acceptable": "Not acceptable",
175
+ "error.408.request_timeout": "Request timeout",
176
+ "error.409.conflict": "Resource already exists",
177
+ "error.410.gone": "Resource has been permanently removed",
178
+ "error.413.payload_too_large": "Request body is too large",
179
+ "error.415.unsupported_media_type": "Unsupported media type",
180
+ "error.422.unprocessable_entity": "Unprocessable entity",
181
+ "error.423.locked": "Resource is locked",
182
+ "error.423.verification_locked": "Too many failed attempts — verification locked",
183
+ "error.429.rate_limit": "Too many attempts. Try again in {retry_after_minutes} minutes.",
184
+ "error.429.too_many_requests": "Too many requests. Please try again later.",
185
+ "error.500.internal": "Something went wrong",
186
+ "error.503.mandate_unavailable": "Cannot verify workspace mandate right now",
187
+ };