@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,96 @@
1
+ /**
2
+ * The GENERIC card — the fallback behind `renderCard`, and the default the
3
+ * spec (§3.7) says the pair must ship.
4
+ *
5
+ * `SearchItem.card` is a free-form object: "stored row fields, so a result
6
+ * page costs one query". Which fields it holds is the DOC TYPE's business,
7
+ * not search's, so this card reads a small, documented set of conventional
8
+ * names and shows what it finds. A storefront replaces the whole thing by
9
+ * passing `renderCard` and rendering `<ListingCard>` from
10
+ * `@stapel/listings-react/default` — that is the slot seam, and the reason
11
+ * these two L2 pairs never import each other.
12
+ *
13
+ * The ONE thing a replacement card may not drop is the `promoted` marking:
14
+ * DSA Art. 26 makes it mandatory, the serializer puts it on every item under
15
+ * every sort, and a storefront where some lists show it and some do not is
16
+ * legally worse than one that never showed it. `renderCard` receives the whole
17
+ * item so the marking is always reachable, and this default renders it.
18
+ */
19
+ import type { ReactElement, ReactNode } from "react";
20
+ import { Card, Flex, Tag, Tooltip, Typography } from "antd";
21
+ import { useT } from "@stapel/core";
22
+ import type { SearchItem } from "../api/types.js";
23
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
24
+
25
+ /** What the card slot is handed. */
26
+ export interface SearchCardProps {
27
+ readonly item: SearchItem;
28
+ }
29
+
30
+ /** A card renderer a host supplies for `renderCard`. */
31
+ export type SearchCardRenderer = (item: SearchItem) => ReactNode;
32
+
33
+ function text(value: unknown): string | undefined {
34
+ if (typeof value === "string" && value.length > 0) return value;
35
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * The conventional field names this default reads off `card`. Documented
41
+ * rather than guessed at each site, and deliberately short: anything richer
42
+ * belongs in the owning pair's own card.
43
+ */
44
+ export const GENERIC_CARD_FIELDS: readonly string[] = [
45
+ "title",
46
+ "price",
47
+ "currency",
48
+ "location",
49
+ "image_url",
50
+ ];
51
+
52
+ export function SearchResultCard(props: SearchCardProps): ReactElement {
53
+ const t = useT();
54
+ const card = props.item.card;
55
+ const title = text(card["title"]) ?? t(SEARCH_I18N_KEYS.resultsUntitled);
56
+ const price = text(card["price"]);
57
+ const currency = text(card["currency"]);
58
+ const location = text(card["location"]);
59
+ const distance =
60
+ props.item.distance_km !== null
61
+ ? t(SEARCH_I18N_KEYS.resultsDistanceKm, {
62
+ km: props.item.distance_km.toFixed(1),
63
+ })
64
+ : undefined;
65
+
66
+ return (
67
+ <Card
68
+ size="small"
69
+ data-testid="search-result-card"
70
+ data-promoted={props.item.promoted ? "true" : "false"}
71
+ >
72
+ <Flex vertical gap={4}>
73
+ <Flex justify="space-between" align="start" gap={8}>
74
+ <Typography.Text strong>{title}</Typography.Text>
75
+ {props.item.promoted && (
76
+ <Tooltip title={t(SEARCH_I18N_KEYS.resultsPromotedHint)}>
77
+ <Tag color="gold" data-testid="search-result-promoted">
78
+ {t(SEARCH_I18N_KEYS.resultsPromoted)}
79
+ </Tag>
80
+ </Tooltip>
81
+ )}
82
+ </Flex>
83
+ {price !== undefined && (
84
+ <Typography.Text>
85
+ {currency !== undefined ? `${price} ${currency}` : price}
86
+ </Typography.Text>
87
+ )}
88
+ {(location !== undefined || distance !== undefined) && (
89
+ <Typography.Text type="secondary">
90
+ {[location, distance].filter((v) => v !== undefined).join(" · ")}
91
+ </Typography.Text>
92
+ )}
93
+ </Flex>
94
+ </Card>
95
+ );
96
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `<SearchResultsPane>` — the antd result page: the count, the degradation
3
+ * banner, the cards, and the keyset controls.
4
+ *
5
+ * Rendered through core's `matchList`, whose FOUR required arms are the point.
6
+ * "Nothing matches this search" is reachable only from a search that actually
7
+ * ran; a 5xx renders "we could not run this search" plus a retry. The
8
+ * substitution of one for the other is what cost the 2026-08-09 incident, and
9
+ * the spec's §7.4 negative leg exercises this exact pane against a forced 5xx.
10
+ *
11
+ * The window refusal gets its own sentence. `error.400.search_window_exceeded`
12
+ * is "narrow the search", not "there is nothing here" — it arrives as a 400
13
+ * with an empty body, which is precisely how it would otherwise render as an
14
+ * empty page.
15
+ */
16
+ import type { ReactElement, ReactNode } from "react";
17
+ import { Button, Empty, Flex, Spin, Typography } from "antd";
18
+ import {
19
+ errorCode,
20
+ matchList,
21
+ toFlowError,
22
+ useDescribeFlowError,
23
+ useT,
24
+ } from "@stapel/core";
25
+ import type { SearchItem } from "../api/types.js";
26
+ import { SearchResults } from "../headless/SearchResults.js";
27
+ import type { SearchResultsBag } from "../headless/SearchResults.js";
28
+ import { SEARCH_WINDOW_EXCEEDED } from "../i18n/errorsMap.js";
29
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
30
+ import { DegradationNotice } from "./DegradationNotice.js";
31
+ import { ErrorAlert } from "./ErrorAlert.js";
32
+ import { SearchResultCard } from "./SearchResultCard.js";
33
+ import type { SearchCardRenderer } from "./SearchResultCard.js";
34
+ import { SearchSkinTheme } from "./theme.js";
35
+ import type { ThemeModeProp } from "./types.js";
36
+
37
+ export interface SearchResultsPaneProps extends ThemeModeProp {
38
+ /**
39
+ * The card slot (spec §6.2 item 1). A storefront passes
40
+ * `(item) => <ListingCard …/>`; omitted, the generic card renders.
41
+ * Either way the `promoted` marking is the renderer's obligation and the
42
+ * default honours it.
43
+ */
44
+ readonly renderCard?: SearchCardRenderer;
45
+ /** Rendered under the pager — where the container puts the ranking link. */
46
+ readonly footer?: ReactNode;
47
+ readonly enabled?: boolean;
48
+ }
49
+
50
+ function Count(props: { bag: SearchResultsBag }): ReactElement | null {
51
+ const t = useT();
52
+ const page = props.bag.page;
53
+ if (page === null) return null;
54
+ return (
55
+ <Typography.Text type="secondary" data-testid="search-count">
56
+ {page.countIsEstimate
57
+ ? t(SEARCH_I18N_KEYS.resultsCountApproximate, { count: page.count })
58
+ : t(SEARCH_I18N_KEYS.resultsCountExact, { count: page.count })}
59
+ </Typography.Text>
60
+ );
61
+ }
62
+
63
+ function Pager(props: { bag: SearchResultsBag }): ReactElement {
64
+ const t = useT();
65
+ const { prev, next, goPrev, goNext } = props.bag;
66
+ return (
67
+ <Flex gap={8} justify="center" data-testid="search-pager">
68
+ <Button
69
+ disabled={!prev.available}
70
+ title={prev.available ? undefined : t(prev.block.code, prev.block.params)}
71
+ onClick={goPrev}
72
+ data-analytics="none"
73
+ data-analytics-reason="keyset paging is a read, not a flow step"
74
+ data-testid="search-prev"
75
+ >
76
+ {t(SEARCH_I18N_KEYS.resultsPrev)}
77
+ </Button>
78
+ <Button
79
+ disabled={!next.available}
80
+ title={next.available ? undefined : t(next.block.code, next.block.params)}
81
+ onClick={goNext}
82
+ data-analytics="none"
83
+ data-analytics-reason="keyset paging is a read, not a flow step"
84
+ data-testid="search-next"
85
+ >
86
+ {t(SEARCH_I18N_KEYS.resultsNext)}
87
+ </Button>
88
+ </Flex>
89
+ );
90
+ }
91
+
92
+ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
93
+ const t = useT();
94
+ const describe = useDescribeFlowError();
95
+ const renderCard = props.renderCard;
96
+
97
+ return (
98
+ <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
99
+ <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
100
+ {(bag) => (
101
+ <Flex vertical gap={16}>
102
+ <Flex justify="space-between" align="center" wrap gap={8}>
103
+ <Typography.Title level={4} style={{ margin: 0 }}>
104
+ {t(SEARCH_I18N_KEYS.resultsTitle)}
105
+ </Typography.Title>
106
+ <Count bag={bag} />
107
+ </Flex>
108
+
109
+ <DegradationNotice degradations={bag.degradations} />
110
+
111
+ {matchList(bag.state, {
112
+ loading: () => (
113
+ <Flex justify="center" style={{ padding: 24 }}>
114
+ <Spin data-testid="search-loading" />
115
+ </Flex>
116
+ ),
117
+ failed: (error) => {
118
+ // The window refusal is a DIFFERENT sentence from a failed
119
+ // search, and both are different from "nothing found".
120
+ const isWindow = errorCode(error) === SEARCH_WINDOW_EXCEEDED;
121
+ return (
122
+ <ErrorAlert
123
+ testId={isWindow ? "search-window-exceeded" : "search-failed"}
124
+ error={{
125
+ ...describe(toFlowError(error)),
126
+ message: t(
127
+ isWindow
128
+ ? SEARCH_I18N_KEYS.resultsWindowExceeded
129
+ : SEARCH_I18N_KEYS.resultsLoadFailed
130
+ ),
131
+ }}
132
+ action={
133
+ <Button
134
+ size="small"
135
+ onClick={bag.refetch}
136
+ data-analytics="none"
137
+ data-analytics-reason="retry of a failed read; no flow to step"
138
+ >
139
+ {t(SEARCH_I18N_KEYS.resultsRetry)}
140
+ </Button>
141
+ }
142
+ />
143
+ );
144
+ },
145
+ empty: () => (
146
+ <Empty
147
+ data-testid="search-empty"
148
+ description={t(SEARCH_I18N_KEYS.resultsEmpty)}
149
+ />
150
+ ),
151
+ ready: (items) => (
152
+ <Flex vertical gap={12} data-testid="search-results">
153
+ {items.map((item: SearchItem) => (
154
+ <div key={item.key}>
155
+ {renderCard !== undefined ? (
156
+ renderCard(item)
157
+ ) : (
158
+ <SearchResultCard item={item} />
159
+ )}
160
+ </div>
161
+ ))}
162
+ </Flex>
163
+ ),
164
+ })}
165
+
166
+ <Pager bag={bag} />
167
+ {props.footer}
168
+ </Flex>
169
+ )}
170
+ </SearchResults>
171
+ </SearchSkinTheme>
172
+ );
173
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The sort control.
3
+ *
4
+ * The options are the shipped `SORTS` list, NOT an enum from the schema —
5
+ * `docs/schema.json` declares none, and a deployment may register more. So a
6
+ * value the URL already carries that is not in the list is still offered as a
7
+ * (raw) option rather than silently reset: resetting it would rewrite a
8
+ * shared link's meaning on load, and the server is the one entitled to refuse
9
+ * an unknown sort (`error.400.search_unknown_sort`, which names it).
10
+ *
11
+ * `sort=distance` needs a centre — the server answers
12
+ * `error.400.search_sort_needs_center` without one. The option is therefore
13
+ * DISABLED with the reason named when no geo centre is set, rather than
14
+ * offered and then refused.
15
+ */
16
+ import type { ReactElement } from "react";
17
+ import { Flex, Select, Typography } from "antd";
18
+ import { useT } from "@stapel/core";
19
+ import { SEARCH_SORTS } from "../api/types.js";
20
+ import { useSearchState } from "../headless/SearchStateProvider.js";
21
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
22
+
23
+ const SORT_LABEL_KEY: Readonly<Record<string, string>> = {
24
+ relevance: SEARCH_I18N_KEYS.sortRelevance,
25
+ newest: SEARCH_I18N_KEYS.sortNewest,
26
+ price_asc: SEARCH_I18N_KEYS.sortPriceAsc,
27
+ price_desc: SEARCH_I18N_KEYS.sortPriceDesc,
28
+ distance: SEARCH_I18N_KEYS.sortDistance,
29
+ };
30
+
31
+ export interface SortSelectProps {
32
+ /** The sort the SERVER applied, shown when the URL names none. */
33
+ readonly appliedSort?: string | undefined;
34
+ }
35
+
36
+ export function SortSelect(props: SortSelectProps): ReactElement {
37
+ const t = useT();
38
+ const { state, setSort } = useSearchState();
39
+
40
+ const hasCentre = state.geo !== undefined;
41
+ const known = new Set(SEARCH_SORTS);
42
+ const values =
43
+ state.sort !== undefined && !known.has(state.sort)
44
+ ? [...SEARCH_SORTS, state.sort]
45
+ : SEARCH_SORTS;
46
+
47
+ return (
48
+ <Flex gap={8} align="center">
49
+ <Typography.Text type="secondary">
50
+ {t(SEARCH_I18N_KEYS.sortLabel)}
51
+ </Typography.Text>
52
+ <Select<string>
53
+ data-testid="search-sort"
54
+ style={{ minWidth: 180 }}
55
+ value={state.sort ?? props.appliedSort ?? null}
56
+ placeholder={t(SEARCH_I18N_KEYS.sortLabel)}
57
+ onChange={(next) => {
58
+ setSort(next);
59
+ }}
60
+ options={values.map((value) => {
61
+ const key = SORT_LABEL_KEY[value];
62
+ const needsCentre = value === "distance" && !hasCentre;
63
+ return {
64
+ value,
65
+ label: key !== undefined ? t(key) : value,
66
+ disabled: needsCentre,
67
+ // A disabled control states its reason (the ActionAvailability
68
+ // canon), even inside a select.
69
+ ...(needsCentre
70
+ ? { title: t("error.400.search_sort_needs_center") }
71
+ : {}),
72
+ };
73
+ })}
74
+ />
75
+ </Flex>
76
+ );
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * "Part of this link could not be read."
3
+ *
4
+ * A shared search is a link somebody typed, edited, or truncated in a chat
5
+ * app. When the codec cannot read a parameter it reports the fact rather than
6
+ * dropping it silently — because the silent version WIDENS the search (a
7
+ * broken `lat` removes the location filter) and the person is then looking at
8
+ * something other than what was shared, with nothing on screen to say so.
9
+ */
10
+ import type { ReactElement } from "react";
11
+ import { Alert } from "antd";
12
+ import { useT } from "@stapel/core";
13
+ import { useSearchState } from "../headless/SearchStateProvider.js";
14
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
15
+
16
+ export function UrlIssueNotice(): ReactElement | null {
17
+ const t = useT();
18
+ const { issues } = useSearchState();
19
+ if (issues.length === 0) return null;
20
+ return (
21
+ <Alert
22
+ type="warning"
23
+ showIcon
24
+ data-testid="search-url-issues"
25
+ message={t(SEARCH_I18N_KEYS.urlIssuesTitle)}
26
+ description={
27
+ <ul style={{ margin: 0, paddingInlineStart: 20 }}>
28
+ {issues.map((issue) => (
29
+ <li key={`${issue.param}:${issue.code}`}>
30
+ {t(issue.messageKey, { param: issue.param })}
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ }
35
+ />
36
+ );
37
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `@stapel/search-react/default` — the antd default skin.
3
+ *
4
+ * A SEPARATE entry point on purpose: the main entry is headless and carries
5
+ * no antd, so a storefront that renders its own cards over `<SearchResults>`
6
+ * never pays for this bundle (enforced by size-limit and the bundle-purity
7
+ * test).
8
+ *
9
+ * Three override levers, none of which requires forking anything:
10
+ *
11
+ * 1. **`renderCard`** — the card slot. A storefront passes
12
+ * `<ListingCard>` from `@stapel/listings-react/default`; the generic card
13
+ * here is the documented default, not the intended end state (spec §3.7).
14
+ * 2. **`categoryFeatures`** — the facet-label slot. The server sends
15
+ * `{value: count}` and no labels; the schema that names them lives in
16
+ * categories, and the container hands it in (spec §6.2 item 2).
17
+ * 3. **retheming through the §68 token JSON** — every surface wraps itself
18
+ * in `<SearchSkinTheme>`, so a host's regenerated `--stapel-*` custom
19
+ * properties reach this skin with zero code.
20
+ */
21
+
22
+ // ── surfaces ────────────────────────────────────────────────────────────────
23
+ export { SearchPage } from "./SearchPage.js";
24
+ export type { SearchPageProps } from "./SearchPage.js";
25
+
26
+ export { SearchResultsPane } from "./SearchResultsPane.js";
27
+ export type { SearchResultsPaneProps } from "./SearchResultsPane.js";
28
+
29
+ export { FacetPanelPane } from "./FacetPanelPane.js";
30
+ export type { FacetPanelPaneProps } from "./FacetPanelPane.js";
31
+
32
+ export { RankingDisclosurePane } from "./RankingDisclosurePane.js";
33
+ export type { RankingDisclosurePaneProps } from "./RankingDisclosurePane.js";
34
+
35
+ // ── parts, exported so a host can compose or wrap one ───────────────────────
36
+ export { SearchResultCard, GENERIC_CARD_FIELDS } from "./SearchResultCard.js";
37
+ export type { SearchCardProps, SearchCardRenderer } from "./SearchResultCard.js";
38
+ export { DegradationNotice } from "./DegradationNotice.js";
39
+ export type { DegradationNoticeProps } from "./DegradationNotice.js";
40
+ export { UrlIssueNotice } from "./UrlIssueNotice.js";
41
+ export { SortSelect } from "./SortSelect.js";
42
+ export type { SortSelectProps } from "./SortSelect.js";
43
+ export { ErrorAlert } from "./ErrorAlert.js";
44
+
45
+ // ── theming ─────────────────────────────────────────────────────────────────
46
+ export { SearchSkinTheme } from "./theme.js";
47
+ export type { SearchSkinThemeProps } from "./theme.js";
48
+ export type { ThemeModeProp } from "./types.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The default skin's INTERNAL theme provider — every `/default` surface wraps
3
+ * itself in this, so the skin is readable no matter what the host supplies
4
+ * (tracker #26: a skin set with no internal provider once inherited a theme
5
+ * bridge serving light-mode values inside a dark document — text on
6
+ * background at 1.00:1). The theme derives from `@stapel/tokens` via
7
+ * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
8
+ * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
9
+ * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
10
+ *
11
+ * This is also what "override styles" means for this pair: a storefront
12
+ * retheming through the §68 token JSON regenerates its `--stapel-*` custom
13
+ * properties and the result page follows with zero code — a far better deal
14
+ * than a prop-per-colour API, and the reason the skin has none.
15
+ */
16
+ import { useMemo } from "react";
17
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
18
+ import { ConfigProvider } from "antd";
19
+ import {
20
+ resolveThemeMode,
21
+ toAntdTheme,
22
+ toAntdThemeConfig,
23
+ } from "@stapel/tokens-antd";
24
+ import type { ThemeMode } from "@stapel/tokens-antd";
25
+
26
+ export interface SearchSkinThemeProps {
27
+ /**
28
+ * Light or dark. Defaults to the mode the host's document declares
29
+ * (`resolveThemeMode()`). Pass explicitly to pin a side.
30
+ */
31
+ readonly mode?: ThemeMode;
32
+ /** Extra styles merged onto the themed root element (layout only — colors
33
+ * come from the mode). */
34
+ readonly style?: CSSProperties;
35
+ readonly children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * `<SearchSkinTheme/>` — the self-theming wrapper of
40
+ * `@stapel/search-react/default`. Standalone use is supported (wrap any
41
+ * composition of the skin's parts once); the shipped surfaces
42
+ * (`SearchPage`, `SearchResultsPane`, `FacetPanelPane`,
43
+ * `RankingDisclosurePane`) each already wrap themselves, and nested antd
44
+ * `ConfigProvider`s merge, so composing them under one `SearchSkinTheme`
45
+ * stays correct.
46
+ */
47
+ export function SearchSkinTheme(props: SearchSkinThemeProps): ReactElement {
48
+ const mode = props.mode ?? resolveThemeMode();
49
+ const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
50
+ const token = useMemo(() => toAntdTheme(mode), [mode]);
51
+ return (
52
+ <ConfigProvider theme={theme}>
53
+ <div
54
+ data-search-skin-root
55
+ data-search-skin-mode={mode}
56
+ style={{
57
+ color: token.colorText,
58
+ backgroundColor: token.colorBgContainer,
59
+ ...props.style,
60
+ }}
61
+ >
62
+ {props.children}
63
+ </div>
64
+ </ConfigProvider>
65
+ );
66
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Small shared types for the `/default` skin — kept in one place so every
3
+ * surface takes the same `mode` prop and re-exports the same error dialect.
4
+ */
5
+ export type { FlowError } from "@stapel/core";
6
+ import type { ThemeMode } from "@stapel/tokens-antd";
7
+
8
+ /** Every `/default` surface accepts a theme mode; absent means "whatever the
9
+ * host document declares" (`resolveThemeMode()`), never a hardcoded side. */
10
+ export interface ThemeModeProp {
11
+ readonly mode?: ThemeMode;
12
+ }
@@ -0,0 +1,113 @@
1
+ import type { ReactNode } from "react";
2
+ import { loadStateFromQuery, mapLoad, useT } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import type { FeatureDef } from "@stapel/attributes-react";
5
+ import type { FacetMeta, SearchRange } from "../api/types.js";
6
+ import { useSearchQuery } from "../model/queries.js";
7
+ import { buildFacetGroups } from "../state/facets.js";
8
+ import type { FacetGroup } from "../state/facets.js";
9
+ import { useSearchState } from "./SearchStateProvider.js";
10
+
11
+ /** The bag `<FacetPanel>` hands its render prop. */
12
+ export interface FacetPanelBag {
13
+ /**
14
+ * The facet groups as a state a skin cannot flatten. `empty` means the
15
+ * search genuinely has no facets to offer (facets off, or no plan for the
16
+ * category) — it is NOT what a failed query looks like.
17
+ */
18
+ readonly state: LoadState<readonly FacetGroup[]>;
19
+ /**
20
+ * `true` when the counts came from a SAMPLE because the candidate set
21
+ * exceeded the backend's cap. The panel must say so — the spec makes this
22
+ * a first-day requirement, not a banner added after somebody notices the
23
+ * numbers moving (§12 risk 3).
24
+ */
25
+ readonly approximate: boolean;
26
+ /**
27
+ * Plan slugs the server dropped at `MAX_FACET_FIELDS`. Reported, not
28
+ * vanished — the backend's own words. A skin names them; it never renders
29
+ * a zero in their place.
30
+ */
31
+ readonly skipped: readonly string[];
32
+ /** Slugs that WERE counted. */
33
+ readonly counted: readonly string[];
34
+ /** Size of the largest counted set — the number `approximate` is about. */
35
+ readonly candidates: number;
36
+ /** Facet values + ranges + geo currently applied. */
37
+ readonly activeFilters: number;
38
+
39
+ toggle(slug: string, value: string): void;
40
+ setRange(slug: string, range: SearchRange | null): void;
41
+ clear(slug: string): void;
42
+ clearAll(): void;
43
+ }
44
+
45
+ const EMPTY_META: FacetMeta = {
46
+ approximate: false,
47
+ candidates: 0,
48
+ counted: [],
49
+ skipped: [],
50
+ };
51
+
52
+ /**
53
+ * Headless facet panel over the CURRENT search.
54
+ *
55
+ * It runs the same query as `<SearchResults>` — same state, same key, so
56
+ * TanStack serves both from one request. That is deliberate: facets and rows
57
+ * come from one envelope server-side (one query per page is the contract's
58
+ * stated design), and fetching them separately would put two answers about
59
+ * one search on the screen at once.
60
+ *
61
+ * `categoryFeatures` is the second slot-seam of the pair (spec §6.2 item 2).
62
+ * The server sends `{value: count}` and no labels, because the labels are
63
+ * translation keys in the category's own schema. Hand them in and the options
64
+ * read as words; leave them out and they read as raw index terms — never as
65
+ * blanks.
66
+ */
67
+ export function FacetPanel(props: {
68
+ /** From `categories-react`'s `GET /categories/{id}/features/`. */
69
+ categoryFeatures?: readonly FeatureDef[];
70
+ /** BCP-47 tag for `date`-typed option labels. Defaults to the runtime's. */
71
+ locale?: string;
72
+ enabled?: boolean;
73
+ children: (bag: FacetPanelBag) => ReactNode;
74
+ }): ReactNode {
75
+ const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
76
+ useSearchState();
77
+ const t = useT();
78
+ const query = useSearchQuery(
79
+ searchState,
80
+ props.enabled !== undefined ? { enabled: props.enabled } : undefined
81
+ );
82
+
83
+ const envelope = loadStateFromQuery(query);
84
+ const meta = envelope.status === "ready" ? envelope.data.facet_meta : EMPTY_META;
85
+
86
+ const groups = mapLoad(envelope, (data) =>
87
+ buildFacetGroups({
88
+ facets: data.facets,
89
+ meta: data.facet_meta,
90
+ state: searchState,
91
+ ...(props.categoryFeatures !== undefined
92
+ ? { categoryFeatures: props.categoryFeatures }
93
+ : {}),
94
+ t,
95
+ ...(props.locale !== undefined ? { locale: props.locale } : {}),
96
+ })
97
+ );
98
+
99
+ return props.children({
100
+ state: groups,
101
+ approximate: meta.approximate,
102
+ skipped: meta.skipped,
103
+ counted: meta.counted,
104
+ candidates: meta.candidates,
105
+ activeFilters,
106
+ toggle: toggleFilter,
107
+ setRange,
108
+ clear: (slug) => {
109
+ setFilter(slug, []);
110
+ },
111
+ clearAll,
112
+ });
113
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react";
2
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import type { RankingResponse, Scorer } from "../api/types.js";
5
+ import { useRankingDisclosure } from "../model/queries.js";
6
+
7
+ /** The bag `<RankingDisclosure>` hands its render prop. */
8
+ export interface RankingDisclosureBag {
9
+ /**
10
+ * The ranking parameters. `empty` means the deployment declares none — a
11
+ * legitimate answer for a naive backend, and a different sentence from
12
+ * "we could not fetch the disclosure".
13
+ */
14
+ readonly state: LoadState<readonly Scorer[]>;
15
+ readonly disclosure: LoadState<RankingResponse>;
16
+ /**
17
+ * Parameters the CONFIGURED engine cannot evaluate (`active: false`). They
18
+ * are listed with their `inactive_reason`, not filtered out: a disclosure
19
+ * that quietly dropped them would be disclosing a ranking the site does not
20
+ * actually use — which is the failure mode P2B Art. 5 exists to prevent.
21
+ */
22
+ readonly inactive: readonly Scorer[];
23
+ readonly docType: string | null;
24
+ readonly backend: string | null;
25
+ readonly notes: readonly string[];
26
+ refetch(): void;
27
+ }
28
+
29
+ /**
30
+ * Headless P2B Art. 5 ranking disclosure — "what determines the order of
31
+ * these results", straight from the backend's scorer registry rather than
32
+ * from prose somebody has to remember to update.
33
+ *
34
+ * Pair this with the DSA Art. 26 half: `SearchItem.promoted` marks the paid
35
+ * placements in the list itself, and this explains the ordering of everything
36
+ * else. Neither substitutes for the other.
37
+ */
38
+ export function RankingDisclosure(props: {
39
+ /** Doc type to disclose. Omitted asks for the deployment's default. */
40
+ type?: string;
41
+ children: (bag: RankingDisclosureBag) => ReactNode;
42
+ }): ReactNode {
43
+ const query = useRankingDisclosure(props.type);
44
+ const disclosure = loadStateFromQuery(query);
45
+ const data = disclosure.status === "ready" ? disclosure.data : null;
46
+
47
+ return props.children({
48
+ state: mapLoad(disclosure, (d) => d.scorers as readonly Scorer[]),
49
+ disclosure,
50
+ inactive: data === null ? [] : data.scorers.filter((s) => !s.active),
51
+ docType: data?.doc_type ?? null,
52
+ backend: data?.backend ?? null,
53
+ notes: data === null ? [] : data.notes,
54
+ refetch: () => {
55
+ void query.refetch();
56
+ },
57
+ });
58
+ }