@stapel/search-react 0.3.1 → 0.7.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 +180 -0
- package/MODULE.md +43 -4
- package/README.md +1 -1
- package/dist/api/generated/schema.d.ts +5 -1
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +34 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +28 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +119 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LanguageSelect.d.ts +9 -0
- package/dist/default/LanguageSelect.d.ts.map +1 -0
- package/dist/default/LanguageSelect.js +72 -0
- package/dist/default/LanguageSelect.js.map +1 -0
- package/dist/default/PageSizeSelect.d.ts +29 -0
- package/dist/default/PageSizeSelect.d.ts.map +1 -0
- package/dist/default/PageSizeSelect.js +25 -0
- package/dist/default/PageSizeSelect.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts +12 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -0
- package/dist/default/RangeFilterRow.js +75 -0
- package/dist/default/RangeFilterRow.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +5 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
- package/dist/default/RankingDisclosurePane.js +15 -15
- package/dist/default/RankingDisclosurePane.js.map +1 -1
- package/dist/default/SearchBox.d.ts +13 -0
- package/dist/default/SearchBox.d.ts.map +1 -0
- package/dist/default/SearchBox.js +67 -0
- package/dist/default/SearchBox.js.map +1 -0
- package/dist/default/SearchPage.d.ts +58 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +155 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts +19 -18
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +178 -5
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +54 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +45 -25
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +27 -24
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/UrlIssueNotice.d.ts.map +1 -1
- package/dist/default/UrlIssueNotice.js +19 -1
- package/dist/default/UrlIssueNotice.js.map +1 -1
- package/dist/default/index.d.ts +33 -16
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +26 -12
- package/dist/default/index.js.map +1 -1
- package/dist/default/sortLabels.d.ts +5 -0
- package/dist/default/sortLabels.d.ts.map +1 -0
- package/dist/default/sortLabels.js +32 -0
- package/dist/default/sortLabels.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +17 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +23 -2
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchResults.d.ts +20 -3
- package/dist/headless/SearchResults.d.ts.map +1 -1
- package/dist/headless/SearchResults.js +3 -1
- package/dist/headless/SearchResults.js.map +1 -1
- package/dist/headless/useAppliedCount.d.ts +8 -0
- package/dist/headless/useAppliedCount.d.ts.map +1 -0
- package/dist/headless/useAppliedCount.js +30 -0
- package/dist/headless/useAppliedCount.js.map +1 -0
- package/dist/headless/useScorerNames.d.ts +4 -0
- package/dist/headless/useScorerNames.d.ts.map +1 -0
- package/dist/headless/useScorerNames.js +34 -0
- package/dist/headless/useScorerNames.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +44 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -0
- package/dist/headless/useSearchBox.js +116 -0
- package/dist/headless/useSearchBox.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +32 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +46 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -6
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +38 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +26 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +38 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/degradations.d.ts +41 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +41 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/limits.d.ts +44 -0
- package/dist/state/limits.d.ts.map +1 -0
- package/dist/state/limits.js +44 -0
- package/dist/state/limits.js.map +1 -0
- package/dist/state/ranges.d.ts +52 -0
- package/dist/state/ranges.d.ts.map +1 -0
- package/dist/state/ranges.js +108 -0
- package/dist/state/ranges.js.map +1 -0
- package/llms.txt +16 -5
- package/manifest.json +245 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +5 -1
- package/src/default/DegradationNotice.tsx +84 -14
- package/src/default/FacetPanelPane.tsx +343 -64
- package/src/default/LanguageSelect.tsx +93 -0
- package/src/default/PageSizeSelect.tsx +80 -0
- package/src/default/RangeFilterRow.tsx +148 -0
- package/src/default/RankingDisclosurePane.tsx +67 -35
- package/src/default/SearchBox.tsx +130 -0
- package/src/default/SearchPage.tsx +356 -41
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +147 -76
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/index.ts +42 -16
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/SearchResults.tsx +32 -4
- package/src/headless/useAppliedCount.ts +41 -0
- package/src/headless/useScorerNames.ts +36 -0
- package/src/headless/useSearchBox.ts +189 -0
- package/src/i18n/es.ts +39 -4
- package/src/i18n/keys.ts +97 -6
- package/src/i18n/ru.ts +45 -4
- package/src/index.ts +30 -3
- package/src/model/queries.ts +51 -1
- package/src/state/degradations.ts +60 -0
- package/src/state/limits.ts +50 -0
- package/src/state/ranges.ts +144 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -24
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -39
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -66
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<SearchBox>` — the control this pair shipped a search page without.
|
|
4
|
+
*
|
|
5
|
+
* `setText` existed from the first release and had ZERO callers in the whole
|
|
6
|
+
* repository: the URL codec could carry `q`, the state machine could set it,
|
|
7
|
+
* the request sent it — and nothing on any screen could type one. A results
|
|
8
|
+
* page reachable only by editing the address bar is not a search feature, so
|
|
9
|
+
* this is the blocker of the pair's audit (S-1) and the reason the page is now
|
|
10
|
+
* a screen rather than a viewer.
|
|
11
|
+
*
|
|
12
|
+
* ── What it does, and what it refuses to do ───────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* - **Types into a draft, searches after a pause.** `useSearchBox` owns the
|
|
15
|
+
* debounce and the URL round-trip; this file is the antd shape of it.
|
|
16
|
+
* - **Suggests from the INDEX.** `GET /suggest` returns title prefixes out of
|
|
17
|
+
* the catalogue — never a query log, because stapel-search keeps none — so
|
|
18
|
+
* every suggestion is a search that has results. The endpoint was typed and
|
|
19
|
+
* unreachable for three releases (S-8).
|
|
20
|
+
* - **Never grows a "no results" dropdown.** With nothing to suggest the menu
|
|
21
|
+
* stays shut: an empty popover under a half-typed word says "there is
|
|
22
|
+
* nothing" about a search that has not run.
|
|
23
|
+
* - **Caps the input at the server's own limit** (200 chars), so
|
|
24
|
+
* `error.400.search_query_too_long` is a refusal this control cannot cause.
|
|
25
|
+
*
|
|
26
|
+
* Exported from `/default` so a container can mount it in the header —
|
|
27
|
+
* `nav/manifest.ts` calls `/s` "a navigation TARGET reached from the header's
|
|
28
|
+
* search box", and this is that box. It needs the same `<SearchStateProvider>`
|
|
29
|
+
* as everything else; a header outside one renders `<SearchPage>`'s copy.
|
|
30
|
+
*/
|
|
31
|
+
import { useState } from "react";
|
|
32
|
+
import { AutoComplete, Button, Flex, Input } from "antd";
|
|
33
|
+
import { useT } from "@stapel/core";
|
|
34
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
35
|
+
import { spacing } from "@stapel/tokens";
|
|
36
|
+
import { useSearchBox } from "../headless/useSearchBox.js";
|
|
37
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
38
|
+
export function SearchBox(props) {
|
|
39
|
+
const t = useT();
|
|
40
|
+
const { mode, placeholder, submitButton, autoFocus, ...boxOptions } = props;
|
|
41
|
+
const box = useSearchBox(boxOptions);
|
|
42
|
+
const [open, setOpen] = useState(false);
|
|
43
|
+
const options = box.suggestions.map((value) => ({ value }));
|
|
44
|
+
return (_jsx(SkinTheme, { surface: "bare", ...(mode !== undefined ? { mode } : {}), "data-testid": "search-box", children: _jsxs(Flex, { gap: spacing[2], align: "center", wrap: false, children: [_jsx(AutoComplete, { value: box.draft, options: options,
|
|
45
|
+
// Shut whenever there is nothing to say — see the header. The
|
|
46
|
+
// open state is the skin's, not antd's: the prop that reports the
|
|
47
|
+
// menu opening was renamed between antd 5 and 6 and this package
|
|
48
|
+
// supports both, so nothing here asks antd when to open.
|
|
49
|
+
open: open && options.length > 0, style: { flex: 1, minWidth: 0 }, onSelect: (value) => {
|
|
50
|
+
setOpen(false);
|
|
51
|
+
box.submit(value);
|
|
52
|
+
}, onChange: (value) => {
|
|
53
|
+
setOpen(true);
|
|
54
|
+
box.setDraft(value);
|
|
55
|
+
}, children: _jsx(Input, { allowClear: {
|
|
56
|
+
clearIcon: (_jsx("span", { "aria-label": t(SEARCH_I18N_KEYS.boxClear), role: "img", children: "×" })),
|
|
57
|
+
}, autoFocus: autoFocus === true, maxLength: box.maxLength, "aria-label": t(SEARCH_I18N_KEYS.boxLabel), placeholder: placeholder ?? t(SEARCH_I18N_KEYS.boxPlaceholder), "data-testid": "search-box-input", "data-analytics": "none", "data-analytics-reason": "typing a query is a read, not a flow step", onPressEnter: () => {
|
|
58
|
+
setOpen(false);
|
|
59
|
+
box.submit();
|
|
60
|
+
}, onBlur: () => {
|
|
61
|
+
setOpen(false);
|
|
62
|
+
} }) }), submitButton !== false && (_jsx(Button, { type: "primary", "data-testid": "search-box-submit", "data-analytics": "none", "data-analytics-reason": "running a search is a read, not a flow step", onClick: () => {
|
|
63
|
+
setOpen(false);
|
|
64
|
+
box.submit();
|
|
65
|
+
}, children: t(SEARCH_I18N_KEYS.boxSubmit) }))] }) }));
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=SearchBox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SearchBox.js","sourceRoot":"","sources":["../../src/default/SearchBox.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,25 +1,25 @@
|
|
|
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
1
|
import type { ReactElement, ReactNode } from "react";
|
|
18
2
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
19
3
|
import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
|
|
20
4
|
import type { ParseSearchStateOptions } from "../state/urlState.js";
|
|
5
|
+
import type { CategoryFilterSlotProps, GeoFilterSlotProps } from "./FacetPanelPane.js";
|
|
6
|
+
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
21
7
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
22
8
|
import type { ThemeModeProp } from "./types.js";
|
|
9
|
+
/** Where the filters live: beside the results, or behind a button in a sheet. */
|
|
10
|
+
export type SearchFiltersLayout = "column" | "sheet";
|
|
11
|
+
/**
|
|
12
|
+
* The desktop filter rail's width.
|
|
13
|
+
*
|
|
14
|
+
* A rail, not a half-page. `Col md={7}` gave the panel a SHARE of the page, so
|
|
15
|
+
* on a wide screen the filters grew with it — the visual pass measured 570 of
|
|
16
|
+
* 1280px (45%) spent on the controls that narrow a list, next to the list they
|
|
17
|
+
* narrow. Filters are a fixed instrument: a checkbox column needs the width of
|
|
18
|
+
* its longest label and nothing more, and every pixel past that comes out of
|
|
19
|
+
* the results. 280px is that width at the default type step, and it is the
|
|
20
|
+
* same panel the phone sheet draws — one component, two frames.
|
|
21
|
+
*/
|
|
22
|
+
export declare const FILTERS_RAIL_WIDTH = 280;
|
|
23
23
|
export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
|
|
24
24
|
/** The URL binding. `useRouterSearchParams()` from `./router` is the
|
|
25
25
|
* react-router one. */
|
|
@@ -27,8 +27,50 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
27
27
|
readonly renderCard?: SearchCardRenderer;
|
|
28
28
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
29
29
|
readonly locale?: string;
|
|
30
|
+
/** Render the query box at the top. `false` for a container whose HEADER
|
|
31
|
+
* already mounts `<SearchBox>` — one box per screen, not two. */
|
|
32
|
+
readonly searchBox?: boolean;
|
|
33
|
+
/** BCP-47 tags this deployment indexes, for the language filter. */
|
|
34
|
+
readonly languages?: readonly string[];
|
|
35
|
+
/** The catalogue picker slot — see {@link FacetPanelPaneProps}. */
|
|
36
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
37
|
+
/** The location control slot (`geo-react`). */
|
|
38
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
30
39
|
/** Container chrome under the results — e.g. the ranking-disclosure link. */
|
|
31
40
|
readonly footer?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* Rendered at the TOP of the filter column, above everything else.
|
|
43
|
+
*
|
|
44
|
+
* The slot exists for a filter this pair cannot ship and has no named seam
|
|
45
|
+
* for. Whatever a host renders here reads and writes the same URL state as
|
|
46
|
+
* the facets beside it (`useSearchState()`), so it is a filter in every
|
|
47
|
+
* sense that matters and not a decoration bolted on top.
|
|
48
|
+
*/
|
|
49
|
+
readonly filtersHeader?: ReactNode;
|
|
50
|
+
/** What this surface calls its result list. See
|
|
51
|
+
* {@link SearchResultsPaneProps.heading}. */
|
|
52
|
+
readonly resultsHeading?: ReactNode;
|
|
53
|
+
/** How the results surface says what the engine could not do (default
|
|
54
|
+
* `"banner"`). Handed straight to `<SearchResultsPane>`. */
|
|
55
|
+
readonly degradationNotice?: DegradationNoticeVariant;
|
|
56
|
+
/**
|
|
57
|
+
* Force the filter surface instead of reading the viewport. For tests and
|
|
58
|
+
* for a host that renders the page inside a phone-width container that is
|
|
59
|
+
* not the viewport — not an escape hatch for "I prefer a column on phones".
|
|
60
|
+
*/
|
|
61
|
+
readonly filtersLayout?: SearchFiltersLayout;
|
|
62
|
+
/**
|
|
63
|
+
* Open the phone filter sheet on mount.
|
|
64
|
+
*
|
|
65
|
+
* For a container that deep-links INTO the filters ("Refine this search"
|
|
66
|
+
* from a category page), and for the story that photographs the sheet —
|
|
67
|
+
* a state reached only by a tap is a state nothing outside a browser has
|
|
68
|
+
* ever seen. The person still closes it; this is the initial value, not a
|
|
69
|
+
* controlled one.
|
|
70
|
+
*/
|
|
71
|
+
readonly defaultFiltersOpen?: boolean;
|
|
72
|
+
/** Offer a page-size control beside the sort. Default `true`. */
|
|
73
|
+
readonly pageSize?: boolean;
|
|
32
74
|
}
|
|
33
75
|
export declare function SearchPage(props: SearchPageProps): ReactElement;
|
|
34
76
|
//# sourceMappingURL=SearchPage.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SearchPage.d.ts","sourceRoot":"","sources":["../../src/default/SearchPage.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SearchPage.d.ts","sourceRoot":"","sources":["../../src/default/SearchPage.tsx"],"names":[],"mappings":"AAkDA,OAAO,KAAK,EAAiB,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAKpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAG9E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAIpE,OAAO,KAAK,EACV,uBAAuB,EACvB,kBAAkB,EACnB,MAAM,qBAAqB,CAAC;AAK7B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AACvE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,iFAAiF;AACjF,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAErD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAYtC,MAAM,WAAW,eAAgB,SAAQ,aAAa,EAAE,uBAAuB;IAC7E;2BACuB;IACvB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;IACzC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;qEACiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,oEAAoE;IACpE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,mEAAmE;IACnE,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC,IAAI,EAAE,uBAAuB,KAAK,SAAS,CAAC;IAC7E,+CAA+C;IAC/C,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,SAAS,CAAC;IACnE,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;IACnC;iDAC6C;IAC7C,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,CAAC;IACpC;gEAC4D;IAC5D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IACtD;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IAC7C;;;;;;;;OAQG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAuMD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,YAAY,CA2C/D"}
|
|
@@ -1,13 +1,162 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<SearchPage>` — the composed screen the nav manifest points at: the query
|
|
4
|
+
* box, the filters, the sort and the results.
|
|
5
|
+
*
|
|
6
|
+
* It composes the panes and owns NO state of its own. The URL is the state,
|
|
7
|
+
* and it arrives through the `adapter` prop — which is where a host hands in
|
|
8
|
+
* `useRouterSearchParams()` from `@stapel/search-react/router`, or its own
|
|
9
|
+
* binding. The page does not reach for a router, so the same component renders
|
|
10
|
+
* under react-router, under a Next.js app router, and in a test with a plain
|
|
11
|
+
* `URLSearchParams`.
|
|
12
|
+
*
|
|
13
|
+
* The seams a storefront fills: `renderCard` (a `<ListingCard>`),
|
|
14
|
+
* `categoryFeatures` (from categories-react, for facet labels and range rows),
|
|
15
|
+
* `renderCategoryFilter` / `renderGeoFilter` (controls other pairs own),
|
|
16
|
+
* `footer` (the container's own chrome), `filtersHeader` and `resultsHeading`.
|
|
17
|
+
* None of them is optional behaviour in disguise — every one has a working
|
|
18
|
+
* default or a visible placeholder.
|
|
19
|
+
*
|
|
20
|
+
* ── The screen could not start a search ───────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Until this release the page rendered filters, sort and results and NO query
|
|
23
|
+
* box: `q` reached the state only from the address bar, and `setText` had zero
|
|
24
|
+
* callers in the repository. A scaffolded app therefore had a search route and
|
|
25
|
+
* no way to search (audit S-1). `<SearchBox>` is now the first thing on the
|
|
26
|
+
* page — and it is exported, so a container that puts the real box in its
|
|
27
|
+
* header can pass `searchBox={false}` and keep exactly one.
|
|
28
|
+
*
|
|
29
|
+
* ── On a phone the filters are a sheet ────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* The filter column was `xs={24}`: on a 390px screen the entire panel stacked
|
|
32
|
+
* ABOVE the results, so a person scrolled past every facet to reach the first
|
|
33
|
+
* card (§83(b), audit S-6). Below the tablet breakpoint the panel now lives
|
|
34
|
+
* behind a "Filters (N)" button and opens as a bottom sheet through the shared
|
|
35
|
+
* `SkinDialog`, with the count of what is applied on the button itself.
|
|
36
|
+
*
|
|
37
|
+
* ── The filter column is laid out only when there is something in it ──────
|
|
38
|
+
*
|
|
39
|
+
* `Col md={7}` was unconditional, and on a deployment whose search plan
|
|
40
|
+
* declares no facets that spent a quarter of every results page — `/s`, every
|
|
41
|
+
* category page, every seller page — on an empty-state illustration saying
|
|
42
|
+
* "no filters for this search". Saying it once is honest; reserving a column
|
|
43
|
+
* for it on every screen is not a message, it is a hole. The page asks the
|
|
44
|
+
* facet bag what it has (`useFacetPanel`) and gives the results the whole
|
|
45
|
+
* width when the answer is nothing AND no other filter control has anything
|
|
46
|
+
* to show. Note which way the test runs: the column stays for `loading` and
|
|
47
|
+
* for `failed`, because a panel that has not answered yet is not a panel with
|
|
48
|
+
* nothing in it, and a layout that reflowed underneath a person mid-load would
|
|
49
|
+
* be worse than the hole.
|
|
50
|
+
*/
|
|
51
|
+
import { useState } from "react";
|
|
52
|
+
import { Button, Flex } from "antd";
|
|
53
|
+
import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
54
|
+
import { useT, useTPlural } from "@stapel/core";
|
|
55
|
+
import { spacing } from "@stapel/tokens";
|
|
56
|
+
import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
|
|
57
|
+
import { useFacetPanel } from "../headless/FacetPanel.js";
|
|
58
|
+
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
59
|
+
import { buildRangeGroups } from "../state/ranges.js";
|
|
60
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
4
61
|
import { FacetPanelPane } from "./FacetPanelPane.js";
|
|
62
|
+
import { PageSizeSelect } from "./PageSizeSelect.js";
|
|
63
|
+
import { SearchBox } from "./SearchBox.js";
|
|
5
64
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
6
65
|
import { SortSelect } from "./SortSelect.js";
|
|
7
|
-
import { SearchSkinTheme } from "./theme.js";
|
|
8
66
|
import { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
67
|
+
/**
|
|
68
|
+
* The desktop filter rail's width.
|
|
69
|
+
*
|
|
70
|
+
* A rail, not a half-page. `Col md={7}` gave the panel a SHARE of the page, so
|
|
71
|
+
* on a wide screen the filters grew with it — the visual pass measured 570 of
|
|
72
|
+
* 1280px (45%) spent on the controls that narrow a list, next to the list they
|
|
73
|
+
* narrow. Filters are a fixed instrument: a checkbox column needs the width of
|
|
74
|
+
* its longest label and nothing more, and every pixel past that comes out of
|
|
75
|
+
* the results. 280px is that width at the default type step, and it is the
|
|
76
|
+
* same panel the phone sheet draws — one component, two frames.
|
|
77
|
+
*/
|
|
78
|
+
export const FILTERS_RAIL_WIDTH = 280;
|
|
79
|
+
/** The rail: fixed, never squeezed, never grown. */
|
|
80
|
+
const RAIL = {
|
|
81
|
+
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
82
|
+
maxWidth: FILTERS_RAIL_WIDTH,
|
|
83
|
+
};
|
|
84
|
+
/** The results take what is left. `minWidth: 0` so a long word inside a card
|
|
85
|
+
* cannot push the grid wider than its column. */
|
|
86
|
+
const RESULTS_COLUMN = { flex: "1 1 auto", minWidth: 0 };
|
|
87
|
+
/**
|
|
88
|
+
* The two columns, inside the state provider — which is where they have to be,
|
|
89
|
+
* because the layout decision reads the same search the panes read.
|
|
90
|
+
*/
|
|
91
|
+
function SearchPageBody(props) {
|
|
92
|
+
const t = useT();
|
|
93
|
+
const tPlural = useTPlural();
|
|
94
|
+
const { categoryFeatures, locale, filtersHeader } = props;
|
|
95
|
+
const { state } = useSearchState();
|
|
96
|
+
const facets = useFacetPanel({
|
|
97
|
+
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
98
|
+
...(locale !== undefined ? { locale } : {}),
|
|
99
|
+
});
|
|
100
|
+
const applied = useAppliedCount();
|
|
101
|
+
const surface = useDialogSurface();
|
|
102
|
+
const layout = props.filtersLayout ?? (surface === "sheet" ? "sheet" : "column");
|
|
103
|
+
const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
|
|
104
|
+
/**
|
|
105
|
+
* "Show 25 results", not "Show results".
|
|
106
|
+
*
|
|
107
|
+
* The sheet's own button is the only place a person learns what the filters
|
|
108
|
+
* they just ticked did — the results are behind it. When the engine cannot
|
|
109
|
+
* say how many there are (`countKind: "unknown"`) the button says so by
|
|
110
|
+
* saying nothing: a fabricated number on the one control that commits the
|
|
111
|
+
* change is worse than a generic verb.
|
|
112
|
+
*/
|
|
113
|
+
const applyLabel = applied.count === null || applied.kind === "unknown"
|
|
114
|
+
? t(SEARCH_I18N_KEYS.filtersApply)
|
|
115
|
+
: tPlural(applied.kind === "at_least"
|
|
116
|
+
? SEARCH_I18N_KEYS.filtersShowCountAtLeast
|
|
117
|
+
: SEARCH_I18N_KEYS.filtersShowCount, { count: applied.count });
|
|
118
|
+
// "Nothing to filter by" is a LOADED answer of zero facet groups AND no
|
|
119
|
+
// other control with anything to say — see the header. Every clause is a
|
|
120
|
+
// control that would otherwise be hidden behind an empty panel: a numeric
|
|
121
|
+
// range the schema declares, a category or a language the URL carries, a
|
|
122
|
+
// host slot that is filled.
|
|
123
|
+
const ranges = buildRangeGroups({
|
|
124
|
+
state,
|
|
125
|
+
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
126
|
+
});
|
|
127
|
+
const filtersEmpty = facets.state.status === "ready" &&
|
|
128
|
+
facets.state.data.length === 0 &&
|
|
129
|
+
facets.activeFilters === 0 &&
|
|
130
|
+
ranges.length === 0 &&
|
|
131
|
+
state.category === undefined &&
|
|
132
|
+
state.lang === undefined &&
|
|
133
|
+
props.renderCategoryFilter === undefined &&
|
|
134
|
+
props.renderGeoFilter === undefined &&
|
|
135
|
+
(props.languages ?? []).length === 0;
|
|
136
|
+
const showFilters = filtersHeader !== undefined || !filtersEmpty;
|
|
137
|
+
const panel = (_jsxs(Flex, { vertical: true, gap: spacing[4], children: [filtersHeader, filtersEmpty ? null : (_jsx(FacetPanelPane, { ...(layout === "sheet" ? { heading: null } : {}), ...(categoryFeatures !== undefined ? { categoryFeatures } : {}), ...(locale !== undefined ? { locale } : {}), ...(props.languages !== undefined ? { languages: props.languages } : {}), ...(props.renderCategoryFilter !== undefined
|
|
138
|
+
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
139
|
+
: {}), ...(props.renderGeoFilter !== undefined
|
|
140
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
141
|
+
: {}) }))] }));
|
|
142
|
+
const toolbar = (_jsxs(Flex, { align: "center", wrap: true, gap: spacing[3], children: [_jsx(SortSelect, {}), props.pageSize !== false && _jsx(PageSizeSelect, {})] }));
|
|
143
|
+
const results = (_jsx(SearchResultsPane, { toolbar: toolbar, ...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {}), ...(props.footer !== undefined ? { footer: props.footer } : {}), ...(props.resultsHeading !== undefined
|
|
144
|
+
? { heading: props.resultsHeading }
|
|
145
|
+
: {}), ...(props.degradationNotice !== undefined
|
|
146
|
+
? { degradationNotice: props.degradationNotice }
|
|
147
|
+
: {}) }));
|
|
148
|
+
return (_jsxs(Flex, { vertical: true, gap: spacing[4], "data-testid": "search-page", "data-filters": showFilters ? "on" : "off", "data-filters-layout": layout, children: [props.searchBox !== false && _jsx(SearchBox, {}), _jsx(UrlIssueNotice, {}), showFilters && layout === "sheet" ? (_jsxs(_Fragment, { children: [_jsx(Button, { block: true, "data-testid": "search-filters-open", "data-analytics": "none", "data-analytics-reason": "opening the filter sheet is a read, not a flow step", onClick: () => {
|
|
149
|
+
setSheetOpen(true);
|
|
150
|
+
}, children: facets.activeFilters === 0
|
|
151
|
+
? t(SEARCH_I18N_KEYS.facetsTitle)
|
|
152
|
+
: t(SEARCH_I18N_KEYS.filtersOpen, { count: facets.activeFilters }) }), _jsx(SkinDialog, { open: sheetOpen, onClose: () => {
|
|
153
|
+
setSheetOpen(false);
|
|
154
|
+
}, title: t(SEARCH_I18N_KEYS.facetsTitle), dismissLabel: t(SEARCH_I18N_KEYS.filtersDismiss), "data-testid": "search-filters-sheet", footer: _jsx(Button, { block: true, type: "primary", "data-testid": "search-filters-apply", "data-analytics": "none", "data-analytics-reason": "the filters are already applied; this closes the sheet", onClick: () => {
|
|
155
|
+
setSheetOpen(false);
|
|
156
|
+
}, children: applyLabel }), children: panel }), results] })) : showFilters ? (_jsxs(Flex, { align: "flex-start", gap: spacing[5], "data-testid": "search-page-columns", children: [_jsx("div", { style: RAIL, children: panel }), _jsx("div", { style: RESULTS_COLUMN, children: results })] })) : (results)] }));
|
|
157
|
+
}
|
|
9
158
|
export function SearchPage(props) {
|
|
10
|
-
const { adapter, renderCard, categoryFeatures, locale, footer, mode, ...parseOptions } = props;
|
|
11
|
-
return (_jsx(
|
|
159
|
+
const { adapter, renderCard, categoryFeatures, locale, searchBox, languages, renderCategoryFilter, renderGeoFilter, footer, filtersHeader, resultsHeading, degradationNotice, filtersLayout, defaultFiltersOpen, pageSize, mode, ...parseOptions } = props;
|
|
160
|
+
return (_jsx(SkinTheme, { surface: "base", ...(mode !== undefined ? { mode } : {}), children: _jsx(SearchStateProvider, { adapter: adapter, ...parseOptions, children: _jsx(SearchPageBody, { ...(renderCard !== undefined ? { renderCard } : {}), ...(categoryFeatures !== undefined ? { categoryFeatures } : {}), ...(locale !== undefined ? { locale } : {}), ...(searchBox !== undefined ? { searchBox } : {}), ...(languages !== undefined ? { languages } : {}), ...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {}), ...(renderGeoFilter !== undefined ? { renderGeoFilter } : {}), ...(footer !== undefined ? { footer } : {}), ...(filtersHeader !== undefined ? { filtersHeader } : {}), ...(resultsHeading !== undefined ? { resultsHeading } : {}), ...(degradationNotice !== undefined ? { degradationNotice } : {}), ...(filtersLayout !== undefined ? { filtersLayout } : {}), ...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {}), ...(pageSize !== undefined ? { pageSize } : {}) }) }) }));
|
|
12
161
|
}
|
|
13
162
|
//# sourceMappingURL=SearchPage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SearchPage.js","sourceRoot":"","sources":["../../src/default/SearchPage.tsx"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"SearchPage.js","sourceRoot":"","sources":["../../src/default/SearchPage.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEjC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEzC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAEzF,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAKrD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAMrD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAEtC,oDAAoD;AACpD,MAAM,IAAI,GAAkB;IAC1B,IAAI,EAAE,OAAO,MAAM,CAAC,kBAAkB,CAAC,IAAI;IAC3C,QAAQ,EAAE,kBAAkB;CAC7B,CAAC;AAEF;iDACiD;AACjD,MAAM,cAAc,GAAkB,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;AAwExE;;;GAGG;AACH,SAAS,cAAc,CAAC,KAA0B;IAChD,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;IAC7B,MAAM,EAAE,gBAAgB,EAAE,MAAM,EAAE,aAAa,EAAE,GAAG,KAAK,CAAC;IAC1D,MAAM,EAAE,KAAK,EAAE,GAAG,cAAc,EAAE,CAAC;IACnC,MAAM,MAAM,GAAG,aAAa,CAAC;QAC3B,GAAG,CAAC,gBAAgB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/D,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5C,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,eAAe,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;IACnC,MAAM,MAAM,GACV,KAAK,CAAC,aAAa,IAAI,CAAC,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpE,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,kBAAkB,KAAK,IAAI,CAAC,CAAC;IAE9E;;;;;;;;OAQG;IACH,MAAM,UAAU,GACd,OAAO,CAAC,KAAK,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;QAClD,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,YAAY,CAAC;QAClC,CAAC,CAAC,OAAO,CACL,OAAO,CAAC,IAAI,KAAK,UAAU;YACzB,CAAC,CAAC,gBAAgB,CAAC,uBAAuB;YAC1C,CAAC,CAAC,gBAAgB,CAAC,gBAAgB,EACrC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CACzB,CAAC;IAER,wEAAwE;IACxE,yEAAyE;IACzE,0EAA0E;IAC1E,yEAAyE;IACzE,4BAA4B;IAC5B,MAAM,MAAM,GAAG,gBAAgB,CAAC;QAC9B,KAAK;QACL,GAAG,CAAC,gBAAgB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAChE,CAAC,CAAC;IACH,MAAM,YAAY,GAChB,MAAM,CAAC,KAAK,CAAC,MAAM,KAAK,OAAO;QAC/B,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC;QAC9B,MAAM,CAAC,aAAa,KAAK,CAAC;QAC1B,MAAM,CAAC,MAAM,KAAK,CAAC;QACnB,KAAK,CAAC,QAAQ,KAAK,SAAS;QAC5B,KAAK,CAAC,IAAI,KAAK,SAAS;QACxB,KAAK,CAAC,oBAAoB,KAAK,SAAS;QACxC,KAAK,CAAC,eAAe,KAAK,SAAS;QACnC,CAAC,KAAK,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;IACvC,MAAM,WAAW,GAAG,aAAa,KAAK,SAAS,IAAI,CAAC,YAAY,CAAC;IAEjE,MAAM,KAAK,GAAG,CACZ,MAAC,IAAI,IAAC,QAAQ,QAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,aAC3B,aAAa,EAOb,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CACrB,KAAC,cAAc,OACT,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC7C,CAAC,gBAAgB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC5D,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACxC,CAAC,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACrE,CAAC,KAAK,CAAC,oBAAoB,KAAK,SAAS;oBAC3C,CAAC,CAAC,EAAE,oBAAoB,EAAE,KAAK,CAAC,oBAAoB,EAAE;oBACtD,CAAC,CAAC,EAAE,CAAC,KACH,CAAC,KAAK,CAAC,eAAe,KAAK,SAAS;oBACtC,CAAC,CAAC,EAAE,eAAe,EAAE,KAAK,CAAC,eAAe,EAAE;oBAC5C,CAAC,CAAC,EAAE,CAAC,GACP,CACH,IACI,CACR,CAAC;IAEF,MAAM,OAAO,GAAG,CACd,MAAC,IAAI,IAAC,KAAK,EAAC,QAAQ,EAAC,IAAI,QAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,aACvC,KAAC,UAAU,KAAG,EACb,KAAK,CAAC,QAAQ,KAAK,KAAK,IAAI,KAAC,cAAc,KAAG,IAC1C,CACR,CAAC;IAEF,MAAM,OAAO,GAAG,CACd,KAAC,iBAAiB,IAChB,OAAO,EAAE,OAAO,KACZ,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACxE,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC5D,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS;YACrC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,cAAc,EAAE;YACnC,CAAC,CAAC,EAAE,CAAC,KACH,CAAC,KAAK,CAAC,iBAAiB,KAAK,SAAS;YACxC,CAAC,CAAC,EAAE,iBAAiB,EAAE,KAAK,CAAC,iBAAiB,EAAE;YAChD,CAAC,CAAC,EAAE,CAAC,GACP,CACH,CAAC;IAEF,OAAO,CACL,MAAC,IAAI,IACH,QAAQ,QACR,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,iBACH,aAAa,kBACX,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,yBACnB,MAAM,aAE1B,KAAK,CAAC,SAAS,KAAK,KAAK,IAAI,KAAC,SAAS,KAAG,EAC3C,KAAC,cAAc,KAAG,EAEjB,WAAW,IAAI,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CACnC,8BACE,KAAC,MAAM,IACL,KAAK,uBACO,qBAAqB,oBAClB,MAAM,2BACC,qDAAqD,EAC3E,OAAO,EAAE,GAAG,EAAE;4BACZ,YAAY,CAAC,IAAI,CAAC,CAAC;wBACrB,CAAC,YAKA,MAAM,CAAC,aAAa,KAAK,CAAC;4BACzB,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,WAAW,CAAC;4BACjC,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,aAAa,EAAE,CAAC,GAC7D,EACT,KAAC,UAAU,IACT,IAAI,EAAE,SAAS,EACf,OAAO,EAAE,GAAG,EAAE;4BACZ,YAAY,CAAC,KAAK,CAAC,CAAC;wBACtB,CAAC,EACD,KAAK,EAAE,CAAC,CAAC,gBAAgB,CAAC,WAAW,CAAC,EACtC,YAAY,EAAE,CAAC,CAAC,gBAAgB,CAAC,cAAc,CAAC,iBACpC,sBAAsB,EAClC,MAAM,EACJ,KAAC,MAAM,IACL,KAAK,QACL,IAAI,EAAC,SAAS,iBACF,sBAAsB,oBACnB,MAAM,2BACC,wDAAwD,EAC9E,OAAO,EAAE,GAAG,EAAE;gCACZ,YAAY,CAAC,KAAK,CAAC,CAAC;4BACtB,CAAC,YAEA,UAAU,GACJ,YAGV,KAAK,GACK,EACZ,OAAO,IACP,CACJ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAChB,MAAC,IAAI,IAAC,KAAK,EAAC,YAAY,EAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,iBAAc,qBAAqB,aACzE,cAAK,KAAK,EAAE,IAAI,YAAG,KAAK,GAAO,EAO/B,cAAK,KAAK,EAAE,cAAc,YAAG,OAAO,GAAO,IACtC,CACR,CAAC,CAAC,CAAC,CACF,OAAO,CACR,IACI,CACR,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAAsB;IAC/C,MAAM,EACJ,OAAO,EACP,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,SAAS,EACT,SAAS,EACT,oBAAoB,EACpB,eAAe,EACf,MAAM,EACN,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,QAAQ,EACR,IAAI,EACJ,GAAG,YAAY,EAChB,GAAG,KAAK,CAAC;IAEV,OAAO,CACL,KAAC,SAAS,IAAC,OAAO,EAAC,MAAM,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,YAChE,KAAC,mBAAmB,IAAC,OAAO,EAAE,OAAO,KAAM,YAAY,YACrD,KAAC,cAAc,OACT,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAChD,CAAC,gBAAgB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC5D,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACxC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC9C,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC9C,CAAC,oBAAoB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,oBAAoB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACpE,CAAC,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC1D,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACxC,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACtD,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACxD,CAAC,iBAAiB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC9D,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACtD,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAChE,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,GAChD,GACkB,GACZ,CACb,CAAC;AACJ,CAAC"}
|
|
@@ -1,22 +1,5 @@
|
|
|
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
1
|
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { Format } from "@stapel/core";
|
|
20
3
|
import type { SearchItem } from "../api/types.js";
|
|
21
4
|
/** What the card slot is handed. */
|
|
22
5
|
export interface SearchCardProps {
|
|
@@ -30,5 +13,23 @@ export type SearchCardRenderer = (item: SearchItem) => ReactNode;
|
|
|
30
13
|
* belongs in the owning pair's own card.
|
|
31
14
|
*/
|
|
32
15
|
export declare const GENERIC_CARD_FIELDS: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The card's price, written the way money is written.
|
|
18
|
+
*
|
|
19
|
+
* `card.price` arrives as a string because the index stores it as one, and
|
|
20
|
+
* `card.currency` as an ISO 4217 code because that is what a document carries.
|
|
21
|
+
* Rendered verbatim they read "3200 RUB": no grouping, the code where the
|
|
22
|
+
* symbol belongs, and the reader doing the arithmetic of where the thousands
|
|
23
|
+
* are. `Intl` knows all of that per locale, so the code goes in and the
|
|
24
|
+
* locale's own rendering comes out.
|
|
25
|
+
*
|
|
26
|
+
* Two ways this refuses to guess. A `price` that is not a finite number is
|
|
27
|
+
* passed through UNCHANGED — a doc type may store "on request", and turning
|
|
28
|
+
* that into `NaN` or into nothing loses what the seller wrote. A `currency`
|
|
29
|
+
* that is not a three-letter code (or one this runtime rejects) falls back to
|
|
30
|
+
* the plain grouped number plus the code, which is still better than the raw
|
|
31
|
+
* pair and never throws inside a render.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatCardPrice(format: Format, price: string | undefined, currency: string | undefined): string | undefined;
|
|
33
34
|
export declare function SearchResultCard(props: SearchCardProps): ReactElement;
|
|
34
35
|
//# sourceMappingURL=SearchResultCard.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SearchResultCard.d.ts","sourceRoot":"","sources":["../../src/default/SearchResultCard.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SearchResultCard.d.ts","sourceRoot":"","sources":["../../src/default/SearchResultCard.tsx"],"names":[],"mappings":"AA8CA,OAAO,KAAK,EAAiB,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAKpE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGlD,oCAAoC;AACpC,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;CAC3B;AAED,wDAAwD;AACxD,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,SAAS,CAAC;AAQjE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAOhD,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,GAAG,SAAS,CAoBpB;AAiFD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,YAAY,CAgGrE"}
|
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The GENERIC card — the fallback behind `renderCard`, and the default the
|
|
4
|
+
* spec (§3.7) says the pair must ship.
|
|
5
|
+
*
|
|
6
|
+
* `SearchItem.card` is a free-form object: "stored row fields, so a result
|
|
7
|
+
* page costs one query". Which fields it holds is the DOC TYPE's business,
|
|
8
|
+
* not search's, so this card reads a small, documented set of conventional
|
|
9
|
+
* names and shows what it finds. A storefront replaces the whole thing by
|
|
10
|
+
* passing `renderCard` and rendering `<ListingCard>` from
|
|
11
|
+
* `@stapel/listings-react/default` — that is the slot seam, and the reason
|
|
12
|
+
* these two L2 pairs never import each other.
|
|
13
|
+
*
|
|
14
|
+
* The ONE thing a replacement card may not drop is the `promoted` marking:
|
|
15
|
+
* DSA Art. 26 makes it mandatory, the serializer puts it on every item under
|
|
16
|
+
* every sort, and a storefront where some lists show it and some do not is
|
|
17
|
+
* legally worse than one that never showed it. `renderCard` receives the whole
|
|
18
|
+
* item so the marking is always reachable, and this default renders it.
|
|
19
|
+
*
|
|
20
|
+
* ── Two things this card used to get wrong ────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 1. **It declared `image_url` and drew nothing.** A text-only card in a
|
|
23
|
+
* classifieds search is not a card; the field was in `GENERIC_CARD_FIELDS`
|
|
24
|
+
* and in no render path. It is drawn now, through `@stapel/image`, so the
|
|
25
|
+
* aspect box lands before the network does and a dead URL renders a named
|
|
26
|
+
* placeholder instead of the browser's torn-page icon.
|
|
27
|
+
* 2. **The marking's EXPLANATION was in a `Tooltip`.** Touch has no hover, so
|
|
28
|
+
* on the device most of this traffic arrives from, the sentence the law is
|
|
29
|
+
* actually about was unreachable — the tag said "Promoted" and nothing on
|
|
30
|
+
* screen said what that meant. It is ordinary text under the tag now
|
|
31
|
+
* (`stapel/no-tooltip-in-skin`), and the tag itself is a `--stapel-*` role
|
|
32
|
+
* rather than an antd preset, because the one legally-mandated marking in
|
|
33
|
+
* the pair has to look the same in every skin a deployment builds.
|
|
34
|
+
* 3. **The tag was white on cream.** `warning-on` is the text colour for the
|
|
35
|
+
* SOLID warning fill; over `warning-bg` the readable role is `warning`
|
|
36
|
+
* itself. The visual pass measured the one legally-mandated string in the
|
|
37
|
+
* package at roughly 1.2:1 — a disclosure nobody can read is the same as
|
|
38
|
+
* no disclosure, in both themes.
|
|
39
|
+
* 4. **The price was a raw amount and an ISO code.** "3200 RUB" is a wire
|
|
40
|
+
* value printed as prose: no grouping, no symbol, and the code in the
|
|
41
|
+
* reader's face. It goes through core's `useFormat().number` with
|
|
42
|
+
* `style: "currency"`, which is the same `Intl` path `@stapel/currencies-
|
|
43
|
+
* react`'s `formatMoney` takes — this pair does not depend on that package
|
|
44
|
+
* (a search index is not a price book) but it must not invent a second
|
|
45
|
+
* way to write money either.
|
|
46
|
+
*/
|
|
47
|
+
import { useMemo } from "react";
|
|
48
|
+
import { Card, Flex, Typography } from "antd";
|
|
49
|
+
import { Image } from "@stapel/image";
|
|
50
|
+
import { useFormat, useT } from "@stapel/core";
|
|
51
|
+
import { cssVar, fontSize, fontWeight, radii, spacing } from "@stapel/tokens";
|
|
4
52
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
5
53
|
function text(value) {
|
|
6
54
|
if (typeof value === "string" && value.length > 0)
|
|
@@ -20,19 +68,144 @@ export const GENERIC_CARD_FIELDS = [
|
|
|
20
68
|
"currency",
|
|
21
69
|
"location",
|
|
22
70
|
"image_url",
|
|
71
|
+
"url",
|
|
23
72
|
];
|
|
73
|
+
/**
|
|
74
|
+
* The card's price, written the way money is written.
|
|
75
|
+
*
|
|
76
|
+
* `card.price` arrives as a string because the index stores it as one, and
|
|
77
|
+
* `card.currency` as an ISO 4217 code because that is what a document carries.
|
|
78
|
+
* Rendered verbatim they read "3200 RUB": no grouping, the code where the
|
|
79
|
+
* symbol belongs, and the reader doing the arithmetic of where the thousands
|
|
80
|
+
* are. `Intl` knows all of that per locale, so the code goes in and the
|
|
81
|
+
* locale's own rendering comes out.
|
|
82
|
+
*
|
|
83
|
+
* Two ways this refuses to guess. A `price` that is not a finite number is
|
|
84
|
+
* passed through UNCHANGED — a doc type may store "on request", and turning
|
|
85
|
+
* that into `NaN` or into nothing loses what the seller wrote. A `currency`
|
|
86
|
+
* that is not a three-letter code (or one this runtime rejects) falls back to
|
|
87
|
+
* the plain grouped number plus the code, which is still better than the raw
|
|
88
|
+
* pair and never throws inside a render.
|
|
89
|
+
*/
|
|
90
|
+
export function formatCardPrice(format, price, currency) {
|
|
91
|
+
if (price === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
const amount = Number(price);
|
|
94
|
+
if (!Number.isFinite(amount))
|
|
95
|
+
return price;
|
|
96
|
+
if (currency !== undefined && /^[A-Za-z]{3}$/.test(currency)) {
|
|
97
|
+
try {
|
|
98
|
+
const money = format.number(amount, {
|
|
99
|
+
style: "currency",
|
|
100
|
+
currency: currency.toUpperCase(),
|
|
101
|
+
currencyDisplay: "narrowSymbol",
|
|
102
|
+
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
|
103
|
+
});
|
|
104
|
+
if (money !== null)
|
|
105
|
+
return money;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// An unsupported currency code: fall through to the grouped number.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const grouped = format.number(amount);
|
|
112
|
+
if (grouped === null)
|
|
113
|
+
return price;
|
|
114
|
+
return currency === undefined ? grouped : `${grouped} ${currency}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The DSA Art. 26 marking, as a token role.
|
|
118
|
+
*
|
|
119
|
+
* `warning` is the §68 dictionary's "look at this, it is not an error" role —
|
|
120
|
+
* the same one every skin in the fleet resolves from its own theme JSON. The
|
|
121
|
+
* previous `<Tag color="gold">` was an antd PRESET: it survived a retheme
|
|
122
|
+
* unchanged, so a deployment with its own palette had one component still
|
|
123
|
+
* painted in Ant Design's gold, and the one marking that must be recognisable
|
|
124
|
+
* everywhere was the one thing the design system did not own.
|
|
125
|
+
*/
|
|
126
|
+
const PROMOTED_TAG = {
|
|
127
|
+
alignSelf: "flex-start",
|
|
128
|
+
background: cssVar("warning-bg"),
|
|
129
|
+
// `warning`, not `warning-on`: the latter is the text colour for the SOLID
|
|
130
|
+
// warning fill (white on light), and over `warning-bg` it is cream on cream.
|
|
131
|
+
color: cssVar("warning"),
|
|
132
|
+
fontWeight: fontWeight.medium,
|
|
133
|
+
border: `1px solid ${cssVar("warning-border")}`,
|
|
134
|
+
borderRadius: radii.sm,
|
|
135
|
+
padding: `0 ${String(spacing[2])}px`,
|
|
136
|
+
fontSize: fontSize.xs.fontSize,
|
|
137
|
+
lineHeight: `${String(fontSize.md.lineHeight)}px`,
|
|
138
|
+
whiteSpace: "nowrap",
|
|
139
|
+
};
|
|
140
|
+
/** The photo's box: a 4:3 well the image fills, drawn before it loads. */
|
|
141
|
+
const PHOTO = {
|
|
142
|
+
width: "100%",
|
|
143
|
+
aspectRatio: "4 / 3",
|
|
144
|
+
overflow: "hidden",
|
|
145
|
+
borderRadius: radii.md,
|
|
146
|
+
background: cssVar("surface-sunken"),
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* A plain `image_url` as the descriptor `<Image>` consumes.
|
|
150
|
+
*
|
|
151
|
+
* A doc type that stores a bare URL has no variant ladder and no dimensions —
|
|
152
|
+
* `source: "link"` is exactly that case, and `<Image>` degrades to the single
|
|
153
|
+
* URL rather than pretending to shop a tier. A doc type that stores the whole
|
|
154
|
+
* `StapelImage` snapshot (`card.image`) gets the ladder, blur-up and all.
|
|
155
|
+
*/
|
|
156
|
+
function cardImage(card) {
|
|
157
|
+
const rich = card["image"];
|
|
158
|
+
if (rich !== null && typeof rich === "object" && "url" in rich) {
|
|
159
|
+
return rich;
|
|
160
|
+
}
|
|
161
|
+
const url = text(card["image_url"]);
|
|
162
|
+
if (url === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
return {
|
|
165
|
+
source: "link",
|
|
166
|
+
url,
|
|
167
|
+
mime: null,
|
|
168
|
+
width: null,
|
|
169
|
+
height: null,
|
|
170
|
+
aspect: null,
|
|
171
|
+
square: false,
|
|
172
|
+
preview_b64: null,
|
|
173
|
+
variants: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The whole card as ONE tap target.
|
|
178
|
+
*
|
|
179
|
+
* A catalogue row is a link — the person taps the picture, the title or the
|
|
180
|
+
* price and expects the same thing to happen. The card used to have no
|
|
181
|
+
* clickable anything at all: the entire result page of a storefront that had
|
|
182
|
+
* not passed `renderCard` was a wall of text with the tap target missing
|
|
183
|
+
* (class C-NOPRIMARY). `card.url` is the conventional field a doc type stores
|
|
184
|
+
* it in; without one the card stays exactly what it was, because inventing a
|
|
185
|
+
* destination is worse than not having one.
|
|
186
|
+
*/
|
|
187
|
+
const CARD_LINK = {
|
|
188
|
+
color: "inherit",
|
|
189
|
+
display: "block",
|
|
190
|
+
textDecoration: "none",
|
|
191
|
+
};
|
|
24
192
|
export function SearchResultCard(props) {
|
|
25
193
|
const t = useT();
|
|
194
|
+
const format = useFormat();
|
|
26
195
|
const card = props.item.card;
|
|
27
196
|
const title = text(card["title"]) ?? t(SEARCH_I18N_KEYS.resultsUntitled);
|
|
28
|
-
const price = text(card["price"]);
|
|
29
|
-
const
|
|
197
|
+
const price = formatCardPrice(format, text(card["price"]), text(card["currency"]));
|
|
198
|
+
const href = text(card["url"]);
|
|
30
199
|
const location = text(card["location"]);
|
|
31
200
|
const distance = props.item.distance_km !== null
|
|
32
201
|
? t(SEARCH_I18N_KEYS.resultsDistanceKm, {
|
|
33
202
|
km: props.item.distance_km.toFixed(1),
|
|
34
203
|
})
|
|
35
204
|
: undefined;
|
|
36
|
-
|
|
205
|
+
// Memoised on the two fields it reads: a fresh `meta` identity every render
|
|
206
|
+
// is a load `<Image>` has to decide is or is not the same one.
|
|
207
|
+
const image = useMemo(() => cardImage(card), [card]);
|
|
208
|
+
const body = (_jsxs(Flex, { vertical: true, gap: spacing[2], children: [image !== undefined && (_jsx("div", { style: PHOTO, "data-testid": "search-result-photo", children: _jsx(Image, { meta: image, alt: t(SEARCH_I18N_KEYS.resultsImageAlt, { title }), fit: "cover", style: { width: "100%", height: "100%" } }) })), _jsxs(Flex, { vertical: true, gap: spacing[1], children: [_jsx(Typography.Text, { strong: true, children: title }), price !== undefined && (_jsx(Typography.Text, { strong: true, style: { fontSize: fontSize.lg.fontSize }, "data-testid": "search-result-price", children: price })), (location !== undefined || distance !== undefined) && (_jsx(Typography.Text, { type: "secondary", children: [location, distance].filter((v) => v !== undefined).join(" · ") }))] })] }));
|
|
209
|
+
return (_jsx(Card, { size: "small", "data-testid": "search-result-card", "data-promoted": props.item.promoted ? "true" : "false", ...(href !== undefined ? { hoverable: true } : {}), styles: { body: { padding: spacing[3] } }, children: _jsxs(Flex, { vertical: true, gap: spacing[2], children: [href === undefined ? (body) : (_jsx("a", { href: href, style: CARD_LINK, "data-testid": "search-result-link", children: body })), props.item.promoted && (_jsxs(Flex, { vertical: true, gap: spacing[1], "data-testid": "search-result-promotion", children: [_jsx("span", { style: PROMOTED_TAG, "data-testid": "search-result-promoted", children: t(SEARCH_I18N_KEYS.resultsPromoted) }), _jsx(Typography.Text, { type: "secondary", style: { fontSize: fontSize.xs.fontSize }, "data-testid": "search-result-promoted-hint", children: t(SEARCH_I18N_KEYS.resultsPromotedHint) })] }))] }) }));
|
|
37
210
|
}
|
|
38
211
|
//# sourceMappingURL=SearchResultCard.js.map
|