@stapel/search-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 (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +203 -0
  3. package/README.md +175 -0
  4. package/dist/api/generated/schema.d.ts +375 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/searchApi.d.ts +77 -0
  9. package/dist/api/searchApi.d.ts.map +1 -0
  10. package/dist/api/searchApi.js +79 -0
  11. package/dist/api/searchApi.js.map +1 -0
  12. package/dist/api/types.d.ts +131 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +20 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/DegradationNotice.d.ts +19 -0
  17. package/dist/default/DegradationNotice.d.ts.map +1 -0
  18. package/dist/default/DegradationNotice.js +14 -0
  19. package/dist/default/DegradationNotice.js.map +1 -0
  20. package/dist/default/ErrorAlert.d.ts +9 -0
  21. package/dist/default/ErrorAlert.d.ts.map +1 -0
  22. package/dist/default/ErrorAlert.js +27 -0
  23. package/dist/default/ErrorAlert.js.map +1 -0
  24. package/dist/default/FacetPanelPane.d.ts +26 -0
  25. package/dist/default/FacetPanelPane.d.ts.map +1 -0
  26. package/dist/default/FacetPanelPane.js +32 -0
  27. package/dist/default/FacetPanelPane.js.map +1 -0
  28. package/dist/default/RankingDisclosurePane.d.ts +17 -0
  29. package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
  30. package/dist/default/RankingDisclosurePane.js +23 -0
  31. package/dist/default/RankingDisclosurePane.js.map +1 -0
  32. package/dist/default/SearchPage.d.ts +34 -0
  33. package/dist/default/SearchPage.d.ts.map +1 -0
  34. package/dist/default/SearchPage.js +16 -0
  35. package/dist/default/SearchPage.js.map +1 -0
  36. package/dist/default/SearchResultCard.d.ts +34 -0
  37. package/dist/default/SearchResultCard.d.ts.map +1 -0
  38. package/dist/default/SearchResultCard.js +38 -0
  39. package/dist/default/SearchResultCard.js.map +1 -0
  40. package/dist/default/SearchResultsPane.d.ts +32 -0
  41. package/dist/default/SearchResultsPane.d.ts.map +1 -0
  42. package/dist/default/SearchResultsPane.js +46 -0
  43. package/dist/default/SearchResultsPane.js.map +1 -0
  44. package/dist/default/SortSelect.d.ts +22 -0
  45. package/dist/default/SortSelect.d.ts.map +1 -0
  46. package/dist/default/SortSelect.js +39 -0
  47. package/dist/default/SortSelect.js.map +1 -0
  48. package/dist/default/UrlIssueNotice.d.ts +12 -0
  49. package/dist/default/UrlIssueNotice.d.ts.map +1 -0
  50. package/dist/default/UrlIssueNotice.js +13 -0
  51. package/dist/default/UrlIssueNotice.js.map +1 -0
  52. package/dist/default/index.d.ts +40 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +34 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/theme.d.ts +24 -0
  57. package/dist/default/theme.d.ts.map +1 -0
  58. package/dist/default/theme.js +39 -0
  59. package/dist/default/theme.js.map +1 -0
  60. package/dist/default/types.d.ts +12 -0
  61. package/dist/default/types.d.ts.map +1 -0
  62. package/dist/default/types.js +2 -0
  63. package/dist/default/types.js.map +1 -0
  64. package/dist/headless/FacetPanel.d.ts +61 -0
  65. package/dist/headless/FacetPanel.d.ts.map +1 -0
  66. package/dist/headless/FacetPanel.js +57 -0
  67. package/dist/headless/FacetPanel.js.map +1 -0
  68. package/dist/headless/RankingDisclosure.d.ts +39 -0
  69. package/dist/headless/RankingDisclosure.d.ts.map +1 -0
  70. package/dist/headless/RankingDisclosure.js +28 -0
  71. package/dist/headless/RankingDisclosure.js.map +1 -0
  72. package/dist/headless/SearchProvider.d.ts +19 -0
  73. package/dist/headless/SearchProvider.d.ts.map +1 -0
  74. package/dist/headless/SearchProvider.js +15 -0
  75. package/dist/headless/SearchProvider.js.map +1 -0
  76. package/dist/headless/SearchResults.d.ts +68 -0
  77. package/dist/headless/SearchResults.d.ts.map +1 -0
  78. package/dist/headless/SearchResults.js +70 -0
  79. package/dist/headless/SearchResults.js.map +1 -0
  80. package/dist/headless/SearchStateProvider.d.ts +69 -0
  81. package/dist/headless/SearchStateProvider.d.ts.map +1 -0
  82. package/dist/headless/SearchStateProvider.js +71 -0
  83. package/dist/headless/SearchStateProvider.js.map +1 -0
  84. package/dist/i18n/errorsMap.d.ts +27 -0
  85. package/dist/i18n/errorsMap.d.ts.map +1 -0
  86. package/dist/i18n/errorsMap.js +37 -0
  87. package/dist/i18n/errorsMap.js.map +1 -0
  88. package/dist/i18n/es.d.ts +13 -0
  89. package/dist/i18n/es.d.ts.map +1 -0
  90. package/dist/i18n/es.js +85 -0
  91. package/dist/i18n/es.js.map +1 -0
  92. package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
  93. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  94. package/dist/i18n/generated/errors.es.gen.js +63 -0
  95. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  96. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  97. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  98. package/dist/i18n/generated/errors.gen.js +183 -0
  99. package/dist/i18n/generated/errors.gen.js.map +1 -0
  100. package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
  101. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  102. package/dist/i18n/generated/errors.ru.gen.js +63 -0
  103. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  104. package/dist/i18n/keys.d.ts +107 -0
  105. package/dist/i18n/keys.d.ts.map +1 -0
  106. package/dist/i18n/keys.js +185 -0
  107. package/dist/i18n/keys.js.map +1 -0
  108. package/dist/i18n/ru.d.ts +29 -0
  109. package/dist/i18n/ru.d.ts.map +1 -0
  110. package/dist/i18n/ru.js +101 -0
  111. package/dist/i18n/ru.js.map +1 -0
  112. package/dist/index.d.ts +72 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +66 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/model/context.d.ts +11 -0
  117. package/dist/model/context.d.ts.map +1 -0
  118. package/dist/model/context.js +16 -0
  119. package/dist/model/context.js.map +1 -0
  120. package/dist/model/queries.d.ts +48 -0
  121. package/dist/model/queries.d.ts.map +1 -0
  122. package/dist/model/queries.js +65 -0
  123. package/dist/model/queries.js.map +1 -0
  124. package/dist/model/queryKeys.d.ts +25 -0
  125. package/dist/model/queryKeys.d.ts.map +1 -0
  126. package/dist/model/queryKeys.js +22 -0
  127. package/dist/model/queryKeys.js.map +1 -0
  128. package/dist/model/runtime.d.ts +25 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +6 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/nav/manifest.d.ts +21 -0
  133. package/dist/nav/manifest.d.ts.map +1 -0
  134. package/dist/nav/manifest.js +34 -0
  135. package/dist/nav/manifest.js.map +1 -0
  136. package/dist/router/index.d.ts +10 -0
  137. package/dist/router/index.d.ts.map +1 -0
  138. package/dist/router/index.js +34 -0
  139. package/dist/router/index.js.map +1 -0
  140. package/dist/state/degradations.d.ts +30 -0
  141. package/dist/state/degradations.d.ts.map +1 -0
  142. package/dist/state/degradations.js +52 -0
  143. package/dist/state/degradations.js.map +1 -0
  144. package/dist/state/facets.d.ts +68 -0
  145. package/dist/state/facets.d.ts.map +1 -0
  146. package/dist/state/facets.js +171 -0
  147. package/dist/state/facets.js.map +1 -0
  148. package/dist/state/urlState.d.ts +143 -0
  149. package/dist/state/urlState.d.ts.map +1 -0
  150. package/dist/state/urlState.js +319 -0
  151. package/dist/state/urlState.js.map +1 -0
  152. package/llms.txt +87 -0
  153. package/manifest.json +652 -0
  154. package/nav-manifest.json +44 -0
  155. package/package.json +133 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/generated/schema.ts +377 -0
  158. package/src/api/searchApi.ts +169 -0
  159. package/src/api/types.ts +160 -0
  160. package/src/default/DegradationNotice.tsx +50 -0
  161. package/src/default/ErrorAlert.tsx +46 -0
  162. package/src/default/FacetPanelPane.tsx +174 -0
  163. package/src/default/RankingDisclosurePane.tsx +116 -0
  164. package/src/default/SearchPage.tsx +87 -0
  165. package/src/default/SearchResultCard.tsx +96 -0
  166. package/src/default/SearchResultsPane.tsx +173 -0
  167. package/src/default/SortSelect.tsx +77 -0
  168. package/src/default/UrlIssueNotice.tsx +37 -0
  169. package/src/default/index.ts +48 -0
  170. package/src/default/theme.tsx +66 -0
  171. package/src/default/types.ts +12 -0
  172. package/src/headless/FacetPanel.tsx +113 -0
  173. package/src/headless/RankingDisclosure.tsx +58 -0
  174. package/src/headless/SearchProvider.tsx +20 -0
  175. package/src/headless/SearchResults.tsx +149 -0
  176. package/src/headless/SearchStateProvider.tsx +175 -0
  177. package/src/i18n/errorsMap.ts +50 -0
  178. package/src/i18n/es.ts +110 -0
  179. package/src/i18n/generated/errors.es.gen.ts +69 -0
  180. package/src/i18n/generated/errors.gen.ts +214 -0
  181. package/src/i18n/generated/errors.json +423 -0
  182. package/src/i18n/generated/errors.ru.gen.ts +69 -0
  183. package/src/i18n/keys.ts +220 -0
  184. package/src/i18n/ru.ts +126 -0
  185. package/src/index.ts +154 -0
  186. package/src/model/context.tsx +28 -0
  187. package/src/model/queries.ts +79 -0
  188. package/src/model/queryKeys.ts +48 -0
  189. package/src/model/runtime.ts +33 -0
  190. package/src/nav/manifest.ts +53 -0
  191. package/src/router/index.ts +38 -0
  192. package/src/state/degradations.ts +75 -0
  193. package/src/state/facets.ts +219 -0
  194. package/src/state/urlState.ts +458 -0
  195. package/tsconfig.json +26 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Wire types for the stapel-search HTTP contract — **derived from the
3
+ * generated OpenAPI surface** (frontend-standard §2/§3), never hand-maintained.
4
+ * The single source of truth is `components["schemas"]` from this pair's own
5
+ * package-LOCAL generated schema (`./generated/schema.js`, produced by
6
+ * `pnpm gen:api` from stapel-search's OWN `docs/schema.json`).
7
+ *
8
+ * Where drf-spectacular under-describes the runtime, this file adds a small
9
+ * documented correction and states what the generator lost.
10
+ */
11
+ import type { components } from "./generated/schema.js";
12
+
13
+ /** The generated schema table — the one source of truth for wire shapes. */
14
+ export type Schemas = components["schemas"];
15
+
16
+ /** `GET /query` 200 — the whole envelope. */
17
+ export type SearchResponse = Schemas["SearchResponse"];
18
+
19
+ /** One result row. `promoted` is present on EVERY item under EVERY sort — a
20
+ * mandatory marking (DSA Art. 26), not an optional field, which is why the
21
+ * card slot's contract carries it and why the default skin renders it. */
22
+ export type SearchItem = Schemas["SearchItem"];
23
+
24
+ /** The honesty block beside the counts: `approximate`, `candidates`,
25
+ * `counted`, `skipped`. Rendered, never swallowed (spec §4.2). */
26
+ export type FacetMeta = Schemas["FacetMeta"];
27
+
28
+ /** `GET /suggest` 200. */
29
+ export type SuggestResponse = Schemas["SuggestResponse"];
30
+
31
+ /** `GET /ranking` 200 — the P2B Art. 5 disclosure. */
32
+ export type RankingResponse = Schemas["RankingResponse"];
33
+
34
+ /** One ranking parameter of the disclosure. */
35
+ export type Scorer = Schemas["Scorer"];
36
+
37
+ /**
38
+ * The sorts stapel-search ships (`conf.py::DEFAULT_SORTS`).
39
+ *
40
+ * GENERATOR NOTE, and the reason `sort` is typed as a plain `string` below:
41
+ * `docs/schema.json` declares **no `enum`** on any query parameter — the sort
42
+ * vocabulary lives in the backend's `SORTS` setting, which a deployment may
43
+ * extend. So this list is what the shipped default offers a sort control, NOT
44
+ * a claim about what the server will accept. An unknown value is refused by
45
+ * the server with `error.400.search_unknown_sort`, naming the value; a client
46
+ * union that silently dropped it would turn a shareable link into an empty
47
+ * page.
48
+ */
49
+ export const SEARCH_SORTS: readonly string[] = [
50
+ "relevance",
51
+ "newest",
52
+ "price_asc",
53
+ "price_desc",
54
+ "distance",
55
+ ];
56
+
57
+ /** Which facets to count: the category's own plan (`"on"`/omitted), none
58
+ * (`"off"`), or an explicit slug list. */
59
+ export type FacetSelection = "on" | "off" | readonly string[];
60
+
61
+ /** One `r.<slug>` range. Either end may be absent — `..500` and `100..` are
62
+ * both legal, and both ends absent means "no range at all". */
63
+ export interface SearchRange {
64
+ readonly from?: string;
65
+ readonly to?: string;
66
+ }
67
+
68
+ /** A point plus an optional radius (`lat`/`lon`/`radius_km`). */
69
+ export interface SearchGeoCenter {
70
+ readonly kind: "center";
71
+ readonly lat: number;
72
+ readonly lon: number;
73
+ readonly radiusKm?: number;
74
+ }
75
+
76
+ /**
77
+ * A bounding box (`bbox=minLat,minLon,maxLat,maxLon`). `minLon > maxLon` is
78
+ * LEGAL and means the box crosses the antimeridian — the codec must not
79
+ * "fix" it, and the backend explicitly allows it (`query.py`).
80
+ */
81
+ export interface SearchGeoBox {
82
+ readonly kind: "bbox";
83
+ readonly minLat: number;
84
+ readonly minLon: number;
85
+ readonly maxLat: number;
86
+ readonly maxLon: number;
87
+ }
88
+
89
+ /** Either geo form. `bbox` wins server-side when both are sent, so the state
90
+ * model carries one or the other and never both. */
91
+ export type SearchGeo = SearchGeoCenter | SearchGeoBox;
92
+
93
+ /**
94
+ * The full query state — everything the URL carries, in one value.
95
+ *
96
+ * `type` is the only required parameter of the endpoint (`error.400
97
+ * .search_unknown_doc_type` names it when absent or unregistered).
98
+ */
99
+ export interface SearchQueryState {
100
+ /** Registered doc type. One type per query; federated search is not in v1. */
101
+ readonly type: string;
102
+ /** Free text. `""` means "no text", which is a valid search (browse). */
103
+ readonly q: string;
104
+ /** Language of the query: picks the analyzer AND narrows the corpus. */
105
+ readonly lang?: string;
106
+ /** `root/leaf` path. A PREFIX filter — a parent finds its descendants. */
107
+ readonly category?: string;
108
+ /** Opaque owner key — the seller's own listings. */
109
+ readonly owner?: string;
110
+ /** `f.<slug>` → the chosen values. Repeat = OR within a slug; different
111
+ * slugs AND together. An empty array is the same as no entry. */
112
+ readonly filters: Readonly<Record<string, readonly string[]>>;
113
+ /** `r.<slug>` → `from..to`. */
114
+ readonly ranges: Readonly<Record<string, SearchRange>>;
115
+ readonly geo?: SearchGeo;
116
+ /** Omitted lets the server choose (`relevance` with text, else `newest`). */
117
+ readonly sort?: string;
118
+ readonly facets?: FacetSelection;
119
+ /** Opaque keyset cursor from a previous answer. */
120
+ readonly anchor?: string;
121
+ readonly direction?: "next" | "prev";
122
+ readonly limit?: number;
123
+ }
124
+
125
+ /** Request shape for `GET /suggest` (typed, but deliberately not hooked —
126
+ * see `searchApi.ts`). */
127
+ export interface SuggestParams {
128
+ readonly type: string;
129
+ readonly q?: string;
130
+ readonly limit?: number;
131
+ }
132
+
133
+ /**
134
+ * One thing the engine could not do for this query, parsed out of the
135
+ * envelope's `degraded[]`.
136
+ *
137
+ * The backend concatenates its own contribution with the backend's and the
138
+ * facet counter's WITHOUT de-duplicating (`services.py`), so the same literal
139
+ * can arrive twice; {@link SearchDegradation} values are de-duplicated by
140
+ * `raw` when parsed.
141
+ */
142
+ export type SearchDegradationKind =
143
+ | "typo_tolerance"
144
+ | "phrase_synonyms"
145
+ | "exact_total"
146
+ | "exact_facet_counts"
147
+ | "category_rollup"
148
+ | "scorer"
149
+ | "unknown";
150
+
151
+ export interface SearchDegradation {
152
+ readonly kind: SearchDegradationKind;
153
+ /** The literal the server sent — shown for an `unknown` kind, because a
154
+ * degradation this build has no sentence for is still a degradation. */
155
+ readonly raw: string;
156
+ /** For `kind: "scorer"`, the scorer slug after the `scorer:` prefix. */
157
+ readonly scorer?: string;
158
+ /** i18n key for the sentence. */
159
+ readonly messageKey: string;
160
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `degraded[]`, on the screen.
3
+ *
4
+ * The whole point of the backend declaring its degradations per query is that
5
+ * a client can tell the person. A banner is not decoration here: "counts are
6
+ * approximate" and "subcategories may be missing" change what the page MEANS,
7
+ * and the spec calls swallowing them the same class of defect as `data ?? []`.
8
+ *
9
+ * An `unknown` degradation still renders — with the raw literal, because a
10
+ * build that predates a new limitation should say "the engine reported
11
+ * something we have no wording for: X", not nothing at all.
12
+ */
13
+ import type { ReactElement } from "react";
14
+ import { Alert, Typography } from "antd";
15
+ import { useT } from "@stapel/core";
16
+ import type { SearchDegradation } from "../api/types.js";
17
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
18
+
19
+ export interface DegradationNoticeProps {
20
+ readonly degradations: readonly SearchDegradation[];
21
+ }
22
+
23
+ export function DegradationNotice(
24
+ props: DegradationNoticeProps
25
+ ): ReactElement | null {
26
+ const t = useT();
27
+ if (props.degradations.length === 0) return null;
28
+ return (
29
+ <Alert
30
+ type="warning"
31
+ showIcon
32
+ data-testid="search-degraded"
33
+ message={t(SEARCH_I18N_KEYS.degradedTitle)}
34
+ description={
35
+ <ul style={{ margin: 0, paddingInlineStart: 20 }}>
36
+ {props.degradations.map((degradation) => (
37
+ <li key={degradation.raw} data-degradation={degradation.raw}>
38
+ <Typography.Text type="secondary">
39
+ {t(degradation.messageKey, {
40
+ scorer: degradation.scorer ?? "",
41
+ raw: degradation.raw,
42
+ })}
43
+ </Typography.Text>
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ }
48
+ />
49
+ );
50
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The one error surface this pair's default skin renders, so the split copy
3
+ * core produces reaches a screen the same way everywhere (mirrors the
4
+ * docs/notifications/workspaces etalon).
5
+ *
6
+ * Core's `describeFlowError` splits a failure into the sentence a person
7
+ * reads and the technical detail a support agent quotes (owner report
8
+ * 2026-08-09: the status used to be spliced into the sentence, ending in a
9
+ * bare `" (500)"`). The split only helps if a skin renders both halves at
10
+ * their own weights: `message` at normal weight, `detail` muted and small.
11
+ *
12
+ * `undefined` in — including a `detail` core left `undefined` because there
13
+ * was nothing worth quoting — renders nothing rather than an empty line.
14
+ */
15
+ import { Alert, Typography } from "antd";
16
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
17
+ import type { FlowErrorDisplay } from "@stapel/core";
18
+
19
+ export function ErrorAlert(props: {
20
+ error: FlowErrorDisplay | undefined;
21
+ style?: CSSProperties | undefined;
22
+ testId?: string | undefined;
23
+ action?: ReactNode;
24
+ }): ReactElement | null {
25
+ const { error } = props;
26
+ if (!error) return null;
27
+ return (
28
+ <Alert
29
+ type="error"
30
+ showIcon
31
+ {...(props.style ? { style: props.style } : {})}
32
+ {...(props.testId ? { "data-testid": props.testId } : {})}
33
+ {...(props.action ? { action: props.action } : {})}
34
+ message={error.message}
35
+ {...(error.detail
36
+ ? {
37
+ description: (
38
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
39
+ {error.detail}
40
+ </Typography.Text>
41
+ ),
42
+ }
43
+ : {})}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `<FacetPanelPane>` — the antd facet panel.
3
+ *
4
+ * Three things it is obliged to render, all of which a naive panel drops:
5
+ *
6
+ * - the count NEXT TO EVERY OPTION, including the ones you have not chosen.
7
+ * Facets are counted with their own filter removed, so those numbers are
8
+ * "what you would get by switching to this instead" — a sibling that shows
9
+ * a stale or zeroed count has converted a drill-down facet into a naive one.
10
+ * - `approximate` — said in words, from the first day, because the counts
11
+ * genuinely are a sample above the backend's candidate cap.
12
+ * - `skipped` — the slugs the server did not count at all. Their options show
13
+ * "not counted", never `0`. A silent zero there is the same defect class as
14
+ * `data ?? []`: a number that looks like an answer and is not one.
15
+ */
16
+ import type { ReactElement } from "react";
17
+ import { Alert, Button, Checkbox, Empty, Flex, Spin, Tag, Typography } from "antd";
18
+ import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
19
+ import type { FeatureDef } from "@stapel/attributes-react";
20
+ import { FacetPanel } from "../headless/FacetPanel.js";
21
+ import type { FacetGroup, FacetOption } from "../state/facets.js";
22
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
23
+ import { ErrorAlert } from "./ErrorAlert.js";
24
+ import { SearchSkinTheme } from "./theme.js";
25
+ import type { ThemeModeProp } from "./types.js";
26
+
27
+ export interface FacetPanelPaneProps extends ThemeModeProp {
28
+ /** The category's feature schema — the source of option LABELS. */
29
+ readonly categoryFeatures?: readonly FeatureDef[];
30
+ readonly locale?: string;
31
+ readonly enabled?: boolean;
32
+ }
33
+
34
+ function OptionRow(props: {
35
+ group: FacetGroup;
36
+ option: FacetOption;
37
+ onToggle: (slug: string, value: string) => void;
38
+ }): ReactElement {
39
+ const t = useT();
40
+ const { option, group } = props;
41
+ return (
42
+ <Flex justify="space-between" align="center" gap={8}>
43
+ <Checkbox
44
+ checked={option.selected}
45
+ data-testid={`facet-option-${group.slug}-${option.value}`}
46
+ data-analytics="none"
47
+ data-analytics-reason="a filter is a read, not a flow step"
48
+ onChange={() => {
49
+ props.onToggle(group.slug, option.value);
50
+ }}
51
+ >
52
+ {option.label}
53
+ </Checkbox>
54
+ {option.count === null ? (
55
+ <Tag data-testid={`facet-count-${group.slug}-${option.value}`}>
56
+ {t(SEARCH_I18N_KEYS.facetsNotCounted)}
57
+ </Tag>
58
+ ) : (
59
+ <Typography.Text
60
+ type="secondary"
61
+ data-testid={`facet-count-${group.slug}-${option.value}`}
62
+ >
63
+ {option.count}
64
+ </Typography.Text>
65
+ )}
66
+ </Flex>
67
+ );
68
+ }
69
+
70
+ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
71
+ const t = useT();
72
+ const describe = useDescribeFlowError();
73
+
74
+ return (
75
+ <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
76
+ <FacetPanel
77
+ {...(props.categoryFeatures !== undefined
78
+ ? { categoryFeatures: props.categoryFeatures }
79
+ : {})}
80
+ {...(props.locale !== undefined ? { locale: props.locale } : {})}
81
+ {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
82
+ >
83
+ {(bag) => (
84
+ <Flex vertical gap={12} data-testid="search-facets">
85
+ <Flex justify="space-between" align="center" gap={8}>
86
+ <Typography.Title level={5} style={{ margin: 0 }}>
87
+ {t(SEARCH_I18N_KEYS.facetsTitle)}
88
+ </Typography.Title>
89
+ {bag.activeFilters > 0 && (
90
+ <Button
91
+ size="small"
92
+ onClick={bag.clearAll}
93
+ data-analytics="none"
94
+ data-analytics-reason="a filter is a read, not a flow step"
95
+ data-testid="facets-clear-all"
96
+ >
97
+ {t(SEARCH_I18N_KEYS.facetsClearAll, { count: bag.activeFilters })}
98
+ </Button>
99
+ )}
100
+ </Flex>
101
+
102
+ {bag.approximate && (
103
+ <Alert
104
+ type="info"
105
+ showIcon
106
+ data-testid="facets-approximate"
107
+ message={t(SEARCH_I18N_KEYS.facetsApproximate)}
108
+ />
109
+ )}
110
+ {bag.skipped.length > 0 && (
111
+ <Alert
112
+ type="warning"
113
+ showIcon
114
+ data-testid="facets-skipped"
115
+ message={t(SEARCH_I18N_KEYS.facetsSkipped, {
116
+ slugs: bag.skipped.join(", "),
117
+ })}
118
+ />
119
+ )}
120
+
121
+ {matchList(bag.state, {
122
+ loading: () => (
123
+ <Flex justify="center" style={{ padding: 16 }}>
124
+ <Spin data-testid="facets-loading" />
125
+ </Flex>
126
+ ),
127
+ failed: (error) => (
128
+ <ErrorAlert
129
+ testId="facets-failed"
130
+ error={{
131
+ ...describe(toFlowError(error)),
132
+ message: t(SEARCH_I18N_KEYS.facetsLoadFailed),
133
+ }}
134
+ />
135
+ ),
136
+ empty: () => (
137
+ <Empty
138
+ data-testid="facets-empty"
139
+ description={t(SEARCH_I18N_KEYS.facetsEmpty)}
140
+ />
141
+ ),
142
+ ready: (groups) => (
143
+ <Flex vertical gap={16}>
144
+ {groups.map((group) => (
145
+ <Flex
146
+ vertical
147
+ gap={4}
148
+ key={group.slug}
149
+ data-testid={`facet-group-${group.slug}`}
150
+ data-counted={group.counted ? "true" : "false"}
151
+ >
152
+ <Typography.Text strong>{group.label}</Typography.Text>
153
+ {group.options.map((option) => (
154
+ <OptionRow
155
+ key={option.value}
156
+ group={group}
157
+ option={option}
158
+ onToggle={bag.toggle}
159
+ />
160
+ ))}
161
+ </Flex>
162
+ ))}
163
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
164
+ {t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
165
+ </Typography.Text>
166
+ </Flex>
167
+ ),
168
+ })}
169
+ </Flex>
170
+ )}
171
+ </FacetPanel>
172
+ </SearchSkinTheme>
173
+ );
174
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `<RankingDisclosurePane>` — the P2B Art. 5 page: which parameters decide
3
+ * the order of results, their relative weight, and which of them the
4
+ * configured engine cannot actually evaluate.
5
+ *
6
+ * Generated from the backend's scorer registry, so it cannot drift from the
7
+ * ranking it describes — which is the only version of this page worth
8
+ * shipping.
9
+ */
10
+ import type { ReactElement } from "react";
11
+ import { Empty, Flex, List, Spin, Tag, Typography } from "antd";
12
+ import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
13
+ import type { Scorer } from "../api/types.js";
14
+ import { RankingDisclosure } from "../headless/RankingDisclosure.js";
15
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
16
+ import { ErrorAlert } from "./ErrorAlert.js";
17
+ import { SearchSkinTheme } from "./theme.js";
18
+ import type { ThemeModeProp } from "./types.js";
19
+
20
+ export interface RankingDisclosurePaneProps extends ThemeModeProp {
21
+ /** Doc type to disclose; omitted asks for the deployment's default. */
22
+ readonly type?: string;
23
+ }
24
+
25
+ export function RankingDisclosurePane(
26
+ props: RankingDisclosurePaneProps
27
+ ): ReactElement {
28
+ const t = useT();
29
+ const describe = useDescribeFlowError();
30
+
31
+ return (
32
+ <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
33
+ <RankingDisclosure {...(props.type !== undefined ? { type: props.type } : {})}>
34
+ {(bag) => (
35
+ <Flex vertical gap={12} data-testid="search-ranking">
36
+ <Typography.Title level={4} style={{ margin: 0 }}>
37
+ {t(SEARCH_I18N_KEYS.rankingTitle)}
38
+ </Typography.Title>
39
+ <Typography.Paragraph type="secondary" style={{ margin: 0 }}>
40
+ {t(SEARCH_I18N_KEYS.rankingIntro)}
41
+ </Typography.Paragraph>
42
+
43
+ {matchList(bag.state, {
44
+ loading: () => (
45
+ <Flex justify="center" style={{ padding: 16 }}>
46
+ <Spin data-testid="ranking-loading" />
47
+ </Flex>
48
+ ),
49
+ failed: (error) => (
50
+ <ErrorAlert
51
+ testId="ranking-failed"
52
+ error={{
53
+ ...describe(toFlowError(error)),
54
+ message: t(SEARCH_I18N_KEYS.rankingLoadFailed),
55
+ }}
56
+ />
57
+ ),
58
+ empty: () => (
59
+ <Empty
60
+ data-testid="ranking-empty"
61
+ description={t(SEARCH_I18N_KEYS.rankingEmpty)}
62
+ />
63
+ ),
64
+ ready: (scorers) => (
65
+ <List<Scorer>
66
+ data-testid="ranking-list"
67
+ dataSource={[...scorers]}
68
+ renderItem={(scorer) => (
69
+ <List.Item key={scorer.slug} data-scorer={scorer.slug}>
70
+ <Flex vertical gap={2} style={{ width: "100%" }}>
71
+ <Flex justify="space-between" align="center" gap={8}>
72
+ <Typography.Text strong>
73
+ {t(scorer.description_key)}
74
+ </Typography.Text>
75
+ <Tag>{scorer.weight}</Tag>
76
+ </Flex>
77
+ <Typography.Text type="secondary">
78
+ {scorer.description}
79
+ </Typography.Text>
80
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
81
+ {t(SEARCH_I18N_KEYS.rankingAppliesTo)}
82
+ {": "}
83
+ {scorer.applies_to_sorts.join(", ")}
84
+ </Typography.Text>
85
+ {!scorer.active && (
86
+ <Typography.Text type="warning" data-testid="ranking-inactive">
87
+ {t(SEARCH_I18N_KEYS.rankingInactive, {
88
+ reason: scorer.inactive_reason,
89
+ })}
90
+ </Typography.Text>
91
+ )}
92
+ </Flex>
93
+ </List.Item>
94
+ )}
95
+ />
96
+ ),
97
+ })}
98
+
99
+ {bag.notes.length > 0 && (
100
+ <Flex vertical gap={2}>
101
+ <Typography.Text strong>
102
+ {t(SEARCH_I18N_KEYS.rankingNotes)}
103
+ </Typography.Text>
104
+ {bag.notes.map((note) => (
105
+ <Typography.Text type="secondary" key={note}>
106
+ {note}
107
+ </Typography.Text>
108
+ ))}
109
+ </Flex>
110
+ )}
111
+ </Flex>
112
+ )}
113
+ </RankingDisclosure>
114
+ </SearchSkinTheme>
115
+ );
116
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `<SearchPage>` — the composed screen the nav manifest points at: filters on
3
+ * the left, sort and results on the right, the ranking link in the footer.
4
+ *
5
+ * It composes the two panes and owns NO state of its own. The URL is the
6
+ * state, and it arrives through the `adapter` prop — which is where a host
7
+ * hands in `useRouterSearchParams()` from `@stapel/search-react/router`, or
8
+ * its own binding. The page does not reach for a router, so the same
9
+ * component renders under react-router, under a Next.js app router, and in a
10
+ * test with a plain `URLSearchParams`.
11
+ *
12
+ * The three seams a storefront fills (spec §6.2 items 1–3): `renderCard`
13
+ * (a `<ListingCard>`), `categoryFeatures` (from categories-react, for facet
14
+ * labels), and `footer` (the container's own chrome). None of them is
15
+ * optional behaviour in disguise — every one has a working default.
16
+ */
17
+ import type { ReactElement, ReactNode } from "react";
18
+ import { Col, Flex, Row, Typography } from "antd";
19
+ import { useT } from "@stapel/core";
20
+ import type { FeatureDef } from "@stapel/attributes-react";
21
+ import { SearchStateProvider } from "../headless/SearchStateProvider.js";
22
+ import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
23
+ import type { ParseSearchStateOptions } from "../state/urlState.js";
24
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
25
+ import { FacetPanelPane } from "./FacetPanelPane.js";
26
+ import { SearchResultsPane } from "./SearchResultsPane.js";
27
+ import { SortSelect } from "./SortSelect.js";
28
+ import type { SearchCardRenderer } from "./SearchResultCard.js";
29
+ import { SearchSkinTheme } from "./theme.js";
30
+ import { UrlIssueNotice } from "./UrlIssueNotice.js";
31
+ import type { ThemeModeProp } from "./types.js";
32
+
33
+ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
34
+ /** The URL binding. `useRouterSearchParams()` from `./router` is the
35
+ * react-router one. */
36
+ readonly adapter: SearchParamsAdapter;
37
+ readonly renderCard?: SearchCardRenderer;
38
+ readonly categoryFeatures?: readonly FeatureDef[];
39
+ readonly locale?: string;
40
+ /** Container chrome under the results — e.g. the ranking-disclosure link. */
41
+ readonly footer?: ReactNode;
42
+ }
43
+
44
+ export function SearchPage(props: SearchPageProps): ReactElement {
45
+ const t = useT();
46
+ const {
47
+ adapter,
48
+ renderCard,
49
+ categoryFeatures,
50
+ locale,
51
+ footer,
52
+ mode,
53
+ ...parseOptions
54
+ } = props;
55
+
56
+ return (
57
+ <SearchSkinTheme {...(mode !== undefined ? { mode } : {})}>
58
+ <SearchStateProvider adapter={adapter} {...parseOptions}>
59
+ <Flex vertical gap={16} data-testid="search-page">
60
+ <UrlIssueNotice />
61
+ <Row gutter={[16, 16]}>
62
+ <Col xs={24} md={7}>
63
+ <FacetPanelPane
64
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
65
+ {...(locale !== undefined ? { locale } : {})}
66
+ />
67
+ </Col>
68
+ <Col xs={24} md={17}>
69
+ <Flex vertical gap={12}>
70
+ <Flex justify="space-between" align="center" wrap gap={8}>
71
+ <Typography.Text type="secondary">
72
+ {t(SEARCH_I18N_KEYS.resultsTitle)}
73
+ </Typography.Text>
74
+ <SortSelect />
75
+ </Flex>
76
+ <SearchResultsPane
77
+ {...(renderCard !== undefined ? { renderCard } : {})}
78
+ {...(footer !== undefined ? { footer } : {})}
79
+ />
80
+ </Flex>
81
+ </Col>
82
+ </Row>
83
+ </Flex>
84
+ </SearchStateProvider>
85
+ </SearchSkinTheme>
86
+ );
87
+ }