@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.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +203 -0
- package/README.md +175 -0
- package/dist/api/generated/schema.d.ts +375 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/searchApi.d.ts +77 -0
- package/dist/api/searchApi.d.ts.map +1 -0
- package/dist/api/searchApi.js +79 -0
- package/dist/api/searchApi.js.map +1 -0
- package/dist/api/types.d.ts +131 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +20 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/DegradationNotice.d.ts +19 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -0
- package/dist/default/DegradationNotice.js +14 -0
- package/dist/default/DegradationNotice.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +26 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -0
- package/dist/default/FacetPanelPane.js +32 -0
- package/dist/default/FacetPanelPane.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +17 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
- package/dist/default/RankingDisclosurePane.js +23 -0
- package/dist/default/RankingDisclosurePane.js.map +1 -0
- package/dist/default/SearchPage.d.ts +34 -0
- package/dist/default/SearchPage.d.ts.map +1 -0
- package/dist/default/SearchPage.js +16 -0
- package/dist/default/SearchPage.js.map +1 -0
- package/dist/default/SearchResultCard.d.ts +34 -0
- package/dist/default/SearchResultCard.d.ts.map +1 -0
- package/dist/default/SearchResultCard.js +38 -0
- package/dist/default/SearchResultCard.js.map +1 -0
- package/dist/default/SearchResultsPane.d.ts +32 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -0
- package/dist/default/SearchResultsPane.js +46 -0
- package/dist/default/SearchResultsPane.js.map +1 -0
- package/dist/default/SortSelect.d.ts +22 -0
- package/dist/default/SortSelect.d.ts.map +1 -0
- package/dist/default/SortSelect.js +39 -0
- package/dist/default/SortSelect.js.map +1 -0
- package/dist/default/UrlIssueNotice.d.ts +12 -0
- package/dist/default/UrlIssueNotice.d.ts.map +1 -0
- package/dist/default/UrlIssueNotice.js +13 -0
- package/dist/default/UrlIssueNotice.js.map +1 -0
- package/dist/default/index.d.ts +40 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +34 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +61 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -0
- package/dist/headless/FacetPanel.js +57 -0
- package/dist/headless/FacetPanel.js.map +1 -0
- package/dist/headless/RankingDisclosure.d.ts +39 -0
- package/dist/headless/RankingDisclosure.d.ts.map +1 -0
- package/dist/headless/RankingDisclosure.js +28 -0
- package/dist/headless/RankingDisclosure.js.map +1 -0
- package/dist/headless/SearchProvider.d.ts +19 -0
- package/dist/headless/SearchProvider.d.ts.map +1 -0
- package/dist/headless/SearchProvider.js +15 -0
- package/dist/headless/SearchProvider.js.map +1 -0
- package/dist/headless/SearchResults.d.ts +68 -0
- package/dist/headless/SearchResults.d.ts.map +1 -0
- package/dist/headless/SearchResults.js +70 -0
- package/dist/headless/SearchResults.js.map +1 -0
- package/dist/headless/SearchStateProvider.d.ts +69 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -0
- package/dist/headless/SearchStateProvider.js +71 -0
- package/dist/headless/SearchStateProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +27 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +37 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +13 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +85 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +63 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +63 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +107 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +185 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +29 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +101 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +48 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +65 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +25 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +22 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +25 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +34 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +34 -0
- package/dist/router/index.js.map +1 -0
- package/dist/state/degradations.d.ts +30 -0
- package/dist/state/degradations.d.ts.map +1 -0
- package/dist/state/degradations.js +52 -0
- package/dist/state/degradations.js.map +1 -0
- package/dist/state/facets.d.ts +68 -0
- package/dist/state/facets.d.ts.map +1 -0
- package/dist/state/facets.js +171 -0
- package/dist/state/facets.js.map +1 -0
- package/dist/state/urlState.d.ts +143 -0
- package/dist/state/urlState.d.ts.map +1 -0
- package/dist/state/urlState.js +319 -0
- package/dist/state/urlState.js.map +1 -0
- package/llms.txt +87 -0
- package/manifest.json +652 -0
- package/nav-manifest.json +44 -0
- package/package.json +133 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +377 -0
- package/src/api/searchApi.ts +169 -0
- package/src/api/types.ts +160 -0
- package/src/default/DegradationNotice.tsx +50 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FacetPanelPane.tsx +174 -0
- package/src/default/RankingDisclosurePane.tsx +116 -0
- package/src/default/SearchPage.tsx +87 -0
- package/src/default/SearchResultCard.tsx +96 -0
- package/src/default/SearchResultsPane.tsx +173 -0
- package/src/default/SortSelect.tsx +77 -0
- package/src/default/UrlIssueNotice.tsx +37 -0
- package/src/default/index.ts +48 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/FacetPanel.tsx +113 -0
- package/src/headless/RankingDisclosure.tsx +58 -0
- package/src/headless/SearchProvider.tsx +20 -0
- package/src/headless/SearchResults.tsx +149 -0
- package/src/headless/SearchStateProvider.tsx +175 -0
- package/src/i18n/errorsMap.ts +50 -0
- package/src/i18n/es.ts +110 -0
- package/src/i18n/generated/errors.es.gen.ts +69 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +423 -0
- package/src/i18n/generated/errors.ru.gen.ts +69 -0
- package/src/i18n/keys.ts +220 -0
- package/src/i18n/ru.ts +126 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +79 -0
- package/src/model/queryKeys.ts +48 -0
- package/src/model/runtime.ts +33 -0
- package/src/nav/manifest.ts +53 -0
- package/src/router/index.ts +38 -0
- package/src/state/degradations.ts +75 -0
- package/src/state/facets.ts +219 -0
- package/src/state/urlState.ts +458 -0
- 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
|
+
}
|