@stapel/search-react 0.11.0 → 0.12.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 +120 -0
- package/MODULE.md +111 -0
- package/README.md +42 -0
- package/dist/api/generated/schema.d.ts +60 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/searchApi.d.ts +13 -6
- package/dist/api/searchApi.d.ts.map +1 -1
- package/dist/api/searchApi.js.map +1 -1
- package/dist/api/types.d.ts +107 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +29 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +13 -2
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +3 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +88 -3
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +161 -13
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +1 -1
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchBox.d.ts.map +1 -1
- package/dist/default/SearchBox.js +74 -4
- package/dist/default/SearchBox.js.map +1 -1
- package/dist/default/SearchPage.d.ts +29 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +25 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +22 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +23 -0
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +29 -9
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +10 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +9 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useFacetLabels.d.ts +39 -0
- package/dist/headless/useFacetLabels.d.ts.map +1 -0
- package/dist/headless/useFacetLabels.js +129 -0
- package/dist/headless/useFacetLabels.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +71 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -1
- package/dist/headless/useSearchBox.js +67 -2
- package/dist/headless/useSearchBox.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +54 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +6 -5
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +4 -3
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +7 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +3 -2
- package/dist/nav/manifest.js.map +1 -1
- package/dist/state/facets.d.ts +56 -15
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +101 -27
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +22 -2
- package/nav-manifest.json +4 -3
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +60 -6
- package/src/api/searchApi.ts +13 -6
- package/src/api/types.ts +115 -1
- package/src/default/FacetPanelPane.tsx +16 -2
- package/src/default/FilterChips.tsx +285 -38
- package/src/default/LocationSummaryLine.tsx +6 -1
- package/src/default/SearchBox.tsx +134 -6
- package/src/default/SearchPage.tsx +79 -5
- package/src/default/SearchResultsPane.tsx +48 -11
- package/src/default/SortSelect.tsx +70 -9
- package/src/default/index.ts +10 -2
- package/src/headless/FacetPanel.tsx +20 -1
- package/src/headless/useFacetLabels.ts +172 -0
- package/src/headless/useSearchBox.ts +132 -5
- package/src/i18n/es.ts +8 -0
- package/src/i18n/keys.ts +56 -0
- package/src/i18n/ru.ts +10 -0
- package/src/index.ts +21 -3
- package/src/model/queries.ts +6 -5
- package/src/model/queryKeys.ts +23 -0
- package/src/nav/manifest.ts +24 -2
- package/src/state/facets.ts +123 -39
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOST LABEL SEAM — the third source of a facet option's caption, and the
|
|
3
|
+
* only one that can reach a vocabulary.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect ────────────────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* A live classified deployment's category schema types `vendor`, `model`,
|
|
8
|
+
* `memory_size` and `color_ref_select` as `ref_select`: their config carries
|
|
9
|
+
* no `options` table at all, only a POINTER —
|
|
10
|
+
* `{"optionsRef": {"level": "Vendor", "vocabulary": "avito-phone-catalog"}}`.
|
|
11
|
+
* The words live in the vocabulary the pointer names, which is a different
|
|
12
|
+
* service with a different client. So the two label sources this pair already
|
|
13
|
+
* has both come up empty: the answer's `facet_labels` because the deployed
|
|
14
|
+
* server predates stapel-search 0.4.0 and sends no such key, and the schema
|
|
15
|
+
* because a pointer is not a table. The chips printed `apple`, `128-gb`,
|
|
16
|
+
* `chernyy` — storage slugs, at buyers.
|
|
17
|
+
*
|
|
18
|
+
* This pair must not grow a vocabulary client to fix that: it would tie every
|
|
19
|
+
* storefront's search to a service it may not run, and the host ALREADY has
|
|
20
|
+
* that client (`@stapel/attributes-react`'s `VocabularyClientProvider` is
|
|
21
|
+
* mounted for the composer). So the fetch is the host's and the discipline is
|
|
22
|
+
* this module's — the same division as `renderGeoFilter` and
|
|
23
|
+
* `renderCategoryFilter`, one seam lower because what crosses it is data
|
|
24
|
+
* rather than a control.
|
|
25
|
+
*
|
|
26
|
+
* ── Precedence, stated once ───────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* 1. the answer's `facet_labels` — the server saw the write-time snapshot;
|
|
29
|
+
* 2. the feature def's inline `options` table — for the plain `select`
|
|
30
|
+
* family, which carries its words with it;
|
|
31
|
+
* 3. this resolver;
|
|
32
|
+
* 4. the raw value.
|
|
33
|
+
*
|
|
34
|
+
* `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
|
|
35
|
+
* and it leaves an option it could not name with `label === value` — which is
|
|
36
|
+
* what makes "did anyone name this?" answerable without a second lookup. Only
|
|
37
|
+
* those values are asked about, so the host is never called for a value the
|
|
38
|
+
* server or the schema already captioned, and a resolver that returns nothing
|
|
39
|
+
* for a value leaves the raw value on screen. A chip that silently dropped an
|
|
40
|
+
* option would be worse than one showing a slug: the option is real, it has a
|
|
41
|
+
* count, and it is the only way to reach those documents.
|
|
42
|
+
*
|
|
43
|
+
* ── The fetch discipline ──────────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* TanStack Query, exactly like the pair's other three reads, and for the four
|
|
46
|
+
* reasons that made it right there:
|
|
47
|
+
*
|
|
48
|
+
* - **batched per group.** One call per facet slug carrying every unresolved
|
|
49
|
+
* value of it, not one call per chip.
|
|
50
|
+
* - **cached.** `staleTime: Infinity` — a vocabulary term's caption changes
|
|
51
|
+
* when somebody edits the catalogue, not between two clicks on a filter.
|
|
52
|
+
* - **deduplicated.** The chip row, the filter panel and the page's layout
|
|
53
|
+
* probe each call `useFacetPanel`, so three components ask for the same
|
|
54
|
+
* group's captions in one render pass; one query key means one request.
|
|
55
|
+
* - **aborted on supersession.** The `signal` is the query's own, so a
|
|
56
|
+
* resolver that honours it stops a request whose answer nobody will read.
|
|
57
|
+
*/
|
|
58
|
+
import { useQueries } from "@tanstack/react-query";
|
|
59
|
+
import { mapLoad } from "@stapel/core";
|
|
60
|
+
import type { LoadState } from "@stapel/core";
|
|
61
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
62
|
+
import { searchQueryKeys } from "../model/queryKeys.js";
|
|
63
|
+
import type { FacetGroup } from "../state/facets.js";
|
|
64
|
+
|
|
65
|
+
/** What the host is asked to name. */
|
|
66
|
+
export interface FacetLabelRequest {
|
|
67
|
+
/** The facet slug — `vendor`, `color_ref_select`. */
|
|
68
|
+
readonly slug: string;
|
|
69
|
+
/**
|
|
70
|
+
* The category's own def for the slug, when the host supplied a schema.
|
|
71
|
+
* It carries the `optionsRef` pointer (vocabulary + level) a resolver needs
|
|
72
|
+
* to know WHICH vocabulary these codes belong to, so a host does not have to
|
|
73
|
+
* keep a second copy of the schema to answer.
|
|
74
|
+
*/
|
|
75
|
+
readonly feature: FeatureDef | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* The values nobody has named yet, sorted. Only the unresolved ones: a
|
|
78
|
+
* resolver is never asked about a value the server or the schema captioned.
|
|
79
|
+
*/
|
|
80
|
+
readonly values: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A host's answer: `{value: caption}`.
|
|
85
|
+
*
|
|
86
|
+
* Partial answers are the expected case, not an error — a code the vocabulary
|
|
87
|
+
* no longer holds simply is not in the map, and its chip keeps printing the
|
|
88
|
+
* raw value. Returning `{}` is a legitimate "I cannot name any of these".
|
|
89
|
+
*/
|
|
90
|
+
export type FacetLabelResolver = (
|
|
91
|
+
request: FacetLabelRequest,
|
|
92
|
+
options: { readonly signal: AbortSignal }
|
|
93
|
+
) => Promise<Readonly<Record<string, string>>>;
|
|
94
|
+
|
|
95
|
+
/** One frozen empty list, so a render with no unresolved values is stable. */
|
|
96
|
+
const NO_REQUESTS: readonly FacetLabelRequest[] = [];
|
|
97
|
+
|
|
98
|
+
/** The values of one group nobody has named — see the precedence note above. */
|
|
99
|
+
function unresolvedValues(group: FacetGroup): readonly string[] {
|
|
100
|
+
return group.options
|
|
101
|
+
.filter((option) => option.label === option.value)
|
|
102
|
+
.map((option) => option.value)
|
|
103
|
+
.sort((a, b) => a.localeCompare(b));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isCaptionMap(value: unknown): value is Readonly<Record<string, string>> {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fill in what the server and the schema could not name, through the host.
|
|
112
|
+
*
|
|
113
|
+
* Returns the groups UNCHANGED — same object identity — when there is no
|
|
114
|
+
* resolver or nothing came back, so a page with the seam unwired pays neither
|
|
115
|
+
* a request nor a re-render.
|
|
116
|
+
*/
|
|
117
|
+
export function useHostFacetLabels(
|
|
118
|
+
groups: LoadState<readonly FacetGroup[]>,
|
|
119
|
+
resolve: FacetLabelResolver | undefined,
|
|
120
|
+
locale: string | undefined
|
|
121
|
+
): LoadState<readonly FacetGroup[]> {
|
|
122
|
+
const requests: readonly FacetLabelRequest[] =
|
|
123
|
+
resolve === undefined || groups.status !== "ready"
|
|
124
|
+
? NO_REQUESTS
|
|
125
|
+
: groups.data
|
|
126
|
+
.map((group) => ({
|
|
127
|
+
slug: group.slug,
|
|
128
|
+
feature: group.feature,
|
|
129
|
+
values: unresolvedValues(group),
|
|
130
|
+
}))
|
|
131
|
+
.filter((request) => request.values.length > 0);
|
|
132
|
+
|
|
133
|
+
const answers = useQueries({
|
|
134
|
+
queries: requests.map((request) => ({
|
|
135
|
+
queryKey: searchQueryKeys.facetLabels(request.slug, request.values, locale),
|
|
136
|
+
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<unknown> =>
|
|
137
|
+
resolve === undefined ? {} : await resolve(request, { signal }),
|
|
138
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
139
|
+
// A vocabulary that answered 500 answers 500 again a millisecond later,
|
|
140
|
+
// and three retries only delay the chip's raw value by three round
|
|
141
|
+
// trips — the same discipline the pair's other reads keep.
|
|
142
|
+
retry: false,
|
|
143
|
+
})),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const captions = new Map<string, Readonly<Record<string, string>>>();
|
|
147
|
+
requests.forEach((request, index) => {
|
|
148
|
+
const data = answers[index]?.data;
|
|
149
|
+
if (isCaptionMap(data)) captions.set(request.slug, data);
|
|
150
|
+
});
|
|
151
|
+
if (captions.size === 0) return groups;
|
|
152
|
+
|
|
153
|
+
return mapLoad(groups, (list) =>
|
|
154
|
+
list.map((group) => {
|
|
155
|
+
const named = captions.get(group.slug);
|
|
156
|
+
if (named === undefined) return group;
|
|
157
|
+
return {
|
|
158
|
+
...group,
|
|
159
|
+
options: group.options.map((option) => {
|
|
160
|
+
// Precedence again, enforced rather than assumed: an option whose
|
|
161
|
+
// label already differs from its value was named by the server or
|
|
162
|
+
// the schema, and the host does not get to overwrite either.
|
|
163
|
+
if (option.label !== option.value) return option;
|
|
164
|
+
const caption = named[option.value];
|
|
165
|
+
return caption === undefined || caption.length === 0
|
|
166
|
+
? option
|
|
167
|
+
: { ...option, label: caption };
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -21,10 +21,36 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The suggestion list is asked for the DEBOUNCED prefix, not the draft — one
|
|
23
23
|
* request per pause, not per keystroke, on an endpoint the backend throttles.
|
|
24
|
+
*
|
|
25
|
+
* ── The box reaches the CATALOGUE, not only the titles ────────────────────
|
|
26
|
+
*
|
|
27
|
+
* stapel-search 0.7.0 answers `/suggest` with two halves. Until it did, typing
|
|
28
|
+
* a word that names a section of the catalogue answered listing titles and
|
|
29
|
+
* nothing else: on a live classified deployment the search field could not
|
|
30
|
+
* reach a category at all, and the owner's own navigation canon rules out both
|
|
31
|
+
* of the usual workarounds — a picker, and a client-side typeahead over the
|
|
32
|
+
* whole tree.
|
|
33
|
+
*
|
|
34
|
+
* The server's answer is neither, and it carries the one thing a client-side
|
|
35
|
+
* matcher can never have: the number of LIVE listings behind each destination,
|
|
36
|
+
* computed as one aggregate over the index and equal to what `/query` reports
|
|
37
|
+
* for the same category. That number is what tells "Menswear / Shorts" from
|
|
38
|
+
* "Childrenswear / Shorts".
|
|
39
|
+
*
|
|
40
|
+
* Everything here is absent-safe. A server that predates 0.7.0 sends no
|
|
41
|
+
* `categories` key, `suggestCategories` is empty, and the box behaves exactly
|
|
42
|
+
* as it did — which is not a hypothetical: the key appeared mid-session on a
|
|
43
|
+
* stand that was redeployed underneath a running client.
|
|
24
44
|
*/
|
|
25
45
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
26
46
|
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
27
47
|
import type { LoadState } from "@stapel/core";
|
|
48
|
+
import {
|
|
49
|
+
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
|
+
SUGGEST_DEGRADED_ROLLUP,
|
|
51
|
+
suggestTerms,
|
|
52
|
+
} from "../api/types.js";
|
|
53
|
+
import type { SuggestAnswer, SuggestCategory } from "../api/types.js";
|
|
28
54
|
import { useSearchState } from "./SearchStateProvider.js";
|
|
29
55
|
import { useSuggest } from "../model/queries.js";
|
|
30
56
|
import {
|
|
@@ -55,6 +81,42 @@ export interface SearchBoxBag {
|
|
|
55
81
|
readonly suggestState: LoadState<readonly string[]>;
|
|
56
82
|
/** A suggestion request is in flight for a prefix nothing is shown for yet. */
|
|
57
83
|
readonly suggestLoading: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* CATEGORY destinations for the typed prefix, server-ranked by live listing
|
|
86
|
+
* count — the primary half of the answer (stapel-search 0.7.0).
|
|
87
|
+
*
|
|
88
|
+
* Empty on a server that sends no `categories` key, and empty when the
|
|
89
|
+
* server could not reach a category provider: see
|
|
90
|
+
* {@link SearchBoxBag.categoriesUnavailable} for why a surface must tell
|
|
91
|
+
* those two apart from "nothing matched".
|
|
92
|
+
*
|
|
93
|
+
* A category with a count of `0` is already filtered out — see
|
|
94
|
+
* {@link SearchBoxBag.categoryCountsUnknown}.
|
|
95
|
+
*/
|
|
96
|
+
readonly categories: readonly SuggestCategory[];
|
|
97
|
+
/**
|
|
98
|
+
* The server HAS no category provider for this answer
|
|
99
|
+
* (`degraded: ["category_suggestions"]`), so `categories` being empty says
|
|
100
|
+
* nothing about the catalogue.
|
|
101
|
+
*
|
|
102
|
+
* A surface must render no group at all rather than an empty one: a heading
|
|
103
|
+
* over nothing is the box telling a person the catalogue has no section by
|
|
104
|
+
* that name, which is a claim this answer did not make. It is deliberately
|
|
105
|
+
* NOT a banner either — the reader is mid-word, the terms half still
|
|
106
|
+
* answers, and a provider being down is the operator's business, the same
|
|
107
|
+
* ruling `degradationAudience` already applies to an engine shortfall.
|
|
108
|
+
*/
|
|
109
|
+
readonly categoriesUnavailable: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The counts on {@link SearchBoxBag.categories} are not answers
|
|
112
|
+
* (`degraded: ["category_rollup"]`): with no ancestry every stored path is
|
|
113
|
+
* one segment long, so every count would read `0` for a mechanical reason.
|
|
114
|
+
*
|
|
115
|
+
* The rows are still real destinations and are still offered; a surface
|
|
116
|
+
* prints the path and omits the number, because a catalogue of zeros is
|
|
117
|
+
* worse than a list with no counts.
|
|
118
|
+
*/
|
|
119
|
+
readonly categoryCountsUnknown: boolean;
|
|
58
120
|
/** Characters a prefix needs before the index is asked. */
|
|
59
121
|
readonly minSuggestChars: number;
|
|
60
122
|
readonly maxLength: number;
|
|
@@ -65,6 +127,25 @@ export interface SearchBoxBag {
|
|
|
65
127
|
submit(value?: string): void;
|
|
66
128
|
/** Empty the box AND the search (an empty `q` is a valid browse). */
|
|
67
129
|
clear(): void;
|
|
130
|
+
/**
|
|
131
|
+
* Follow a category suggestion: narrow the SERP to that section.
|
|
132
|
+
*
|
|
133
|
+
* Two decisions, both load-bearing.
|
|
134
|
+
*
|
|
135
|
+
* The `category` parameter is set to the server's own
|
|
136
|
+
* {@link SuggestCategory.category} string, VERBATIM. The server joins the
|
|
137
|
+
* ancestry itself precisely so a client cannot invent a different join and
|
|
138
|
+
* silently miss, so nothing here rebuilds it from the path or the slug.
|
|
139
|
+
*
|
|
140
|
+
* The typed text is CLEARED in the same write. The row promised a count —
|
|
141
|
+
* "Menswear / Shorts, 1 240 listings" — and that number is the
|
|
142
|
+
* section's, not the section's intersected with a title search for the word
|
|
143
|
+
* that found it. Keeping `q` would land a person on strictly fewer results
|
|
144
|
+
* than the row they tapped had just quoted, which is the row lying about
|
|
145
|
+
* where it goes. One `patch` call, so it is also one history entry: Back
|
|
146
|
+
* returns to the search the person typed.
|
|
147
|
+
*/
|
|
148
|
+
chooseCategory(category: SuggestCategory): void;
|
|
68
149
|
}
|
|
69
150
|
|
|
70
151
|
export interface UseSearchBoxOptions {
|
|
@@ -81,8 +162,39 @@ export interface UseSearchBoxOptions {
|
|
|
81
162
|
/** One frozen empty list, so a render with no suggestions is a stable value. */
|
|
82
163
|
const NO_SUGGESTIONS: readonly string[] = [];
|
|
83
164
|
|
|
165
|
+
/** The same, for the categories half. */
|
|
166
|
+
const NO_CATEGORIES: readonly SuggestCategory[] = [];
|
|
167
|
+
|
|
168
|
+
/** Does this answer name a shortfall? Absent-safe on every older server. */
|
|
169
|
+
function degradedWith(answer: SuggestAnswer | undefined, literal: string): boolean {
|
|
170
|
+
return answer?.degraded?.includes(literal) === true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The destinations worth offering out of one answer.
|
|
175
|
+
*
|
|
176
|
+
* A category with a count of `0` is a section with nothing in it: following it
|
|
177
|
+
* lands the buyer on an empty SERP, which is a dead end dressed as a
|
|
178
|
+
* destination — and the server does return those (it reads the count with a
|
|
179
|
+
* `0` default and ranks them last), so somebody has to drop them.
|
|
180
|
+
*
|
|
181
|
+
* The one exception is `category_rollup`, where EVERY count is `0` because the
|
|
182
|
+
* ancestry never arrived rather than because the sections are empty. Filtering
|
|
183
|
+
* on the count there would delete the whole group for a reason that has
|
|
184
|
+
* nothing to do with what is in the catalogue, so the rows are kept and the
|
|
185
|
+
* numbers are what the surface omits.
|
|
186
|
+
*/
|
|
187
|
+
export function offerableCategories(
|
|
188
|
+
answer: SuggestAnswer | undefined
|
|
189
|
+
): readonly SuggestCategory[] {
|
|
190
|
+
const categories = answer?.categories;
|
|
191
|
+
if (categories === undefined || categories.length === 0) return NO_CATEGORIES;
|
|
192
|
+
if (degradedWith(answer, SUGGEST_DEGRADED_ROLLUP)) return categories;
|
|
193
|
+
return categories.filter((category) => category.count > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
84
196
|
export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
85
|
-
const { state, setText } = useSearchState();
|
|
197
|
+
const { state, setText, patch } = useSearchState();
|
|
86
198
|
const committed = state.q;
|
|
87
199
|
const debounceMs = options.debounceMs ?? SEARCH_BOX_DEBOUNCE_MS;
|
|
88
200
|
|
|
@@ -168,10 +280,21 @@ export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
|
168
280
|
enabled: options.suggest !== false,
|
|
169
281
|
});
|
|
170
282
|
|
|
171
|
-
const suggestState = mapLoad(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
283
|
+
const suggestState = mapLoad(loadStateFromQuery(suggest), suggestTerms);
|
|
284
|
+
|
|
285
|
+
// The READY answer only. A category row is a navigation control, and one
|
|
286
|
+
// rendered out of a stale answer would send a person somewhere the current
|
|
287
|
+
// prefix never named.
|
|
288
|
+
const answer = suggest.data;
|
|
289
|
+
const categories = offerableCategories(answer);
|
|
290
|
+
|
|
291
|
+
const chooseCategory = (category: SuggestCategory): void => {
|
|
292
|
+
setDraftState("");
|
|
293
|
+
cancel();
|
|
294
|
+
// One write, so one history entry — see `SearchBoxBag.chooseCategory` for
|
|
295
|
+
// why the text goes with it.
|
|
296
|
+
patch({ q: "", category: category.category });
|
|
297
|
+
};
|
|
175
298
|
|
|
176
299
|
return {
|
|
177
300
|
draft,
|
|
@@ -180,10 +303,14 @@ export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
|
180
303
|
suggestions: suggestState.status === "ready" ? suggestState.data : NO_SUGGESTIONS,
|
|
181
304
|
suggestState,
|
|
182
305
|
suggestLoading: suggest.isLoading && suggest.fetchStatus === "fetching",
|
|
306
|
+
categories,
|
|
307
|
+
categoriesUnavailable: degradedWith(answer, SUGGEST_DEGRADED_CATEGORIES),
|
|
308
|
+
categoryCountsUnknown: degradedWith(answer, SUGGEST_DEGRADED_ROLLUP),
|
|
183
309
|
minSuggestChars: SUGGEST_MIN_CHARS,
|
|
184
310
|
maxLength: SEARCH_QUERY_MAX_CHARS,
|
|
185
311
|
setDraft,
|
|
186
312
|
submit,
|
|
187
313
|
clear,
|
|
314
|
+
chooseCategory,
|
|
188
315
|
};
|
|
189
316
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -46,6 +46,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
46
46
|
"search.box.submit": "Buscar",
|
|
47
47
|
"search.box.clear": "Borrar la búsqueda",
|
|
48
48
|
"search.box.suggestions": "Sugerencias",
|
|
49
|
+
"search.box.categories": "Secciones",
|
|
50
|
+
"search.box.category_count.one": "{count} anuncio",
|
|
51
|
+
"search.box.category_count.other": "{count} anuncios",
|
|
49
52
|
|
|
50
53
|
"search.sort.label": "Orden",
|
|
51
54
|
"search.sort.relevance": "Más relevantes",
|
|
@@ -93,6 +96,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
93
96
|
"search.filters.dismiss": "Cerrar los filtros",
|
|
94
97
|
"search.filters.chips_label": "Filtros",
|
|
95
98
|
"search.filters.all": "Todos los filtros",
|
|
99
|
+
"search.filters.short": "Filtros",
|
|
96
100
|
"search.filters.chip_more": ", +{count}",
|
|
97
101
|
|
|
98
102
|
"search.category.title": "Categoría",
|
|
@@ -150,6 +154,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
150
154
|
"search.ranking.inactive": "No aplicado: {reason}",
|
|
151
155
|
"search.ranking.notes": "Notas",
|
|
152
156
|
"search.ranking.link": "Cómo se ordenan estos resultados",
|
|
157
|
+
|
|
158
|
+
"search.nav.results": "Buscar",
|
|
159
|
+
"search.nav.ranking": "Orden de resultados",
|
|
160
|
+
"search.nav.ranking.short": "Orden",
|
|
153
161
|
};
|
|
154
162
|
|
|
155
163
|
/** Register the es bundle. Call AFTER `registerSearchI18n`. */
|
package/src/i18n/keys.ts
CHANGED
|
@@ -64,6 +64,22 @@ export const SEARCH_I18N_KEYS = {
|
|
|
64
64
|
boxClear: "search.box.clear",
|
|
65
65
|
/** Accessible name of the suggestion list under the box. */
|
|
66
66
|
boxSuggestions: "search.box.suggestions",
|
|
67
|
+
/**
|
|
68
|
+
* The heading over the CATEGORY half of the type-ahead (stapel-search
|
|
69
|
+
* 0.7.0). The rows under it are DESTINATIONS — a section of the catalogue —
|
|
70
|
+
* and not, like everything else in the menu, another set of words to search
|
|
71
|
+
* for; a group with no heading would read as more of the same.
|
|
72
|
+
*/
|
|
73
|
+
boxCategories: "search.box.categories",
|
|
74
|
+
/**
|
|
75
|
+
* "1 240 listings" beside a category row — how many LIVE listings a buyer
|
|
76
|
+
* would see there, the same number the SERP reports for it.
|
|
77
|
+
*
|
|
78
|
+
* A PLURAL FAMILY: render with `tPlural`, never `t`. It counts a noun in
|
|
79
|
+
* words, so Russian needs its four endings and a single string would be
|
|
80
|
+
* right only for 5-20 — the exact defect the results count already fixed.
|
|
81
|
+
*/
|
|
82
|
+
boxCategoryCount: "search.box.category_count",
|
|
67
83
|
|
|
68
84
|
// ── sort ─────────────────────────────────────────────────────────────────
|
|
69
85
|
sortLabel: "search.sort.label",
|
|
@@ -129,6 +145,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
129
145
|
/** The leading, icon-only chip: the whole panel. Icon-only means the name
|
|
130
146
|
* exists ONLY here, so this key is the control's entire accessibility. */
|
|
131
147
|
filtersAll: "search.filters.all",
|
|
148
|
+
/** The same door, named for a 390px row: the location line's trailing link
|
|
149
|
+
* sits beside a place name and cannot spend six characters on "All". */
|
|
150
|
+
filtersShort: "search.filters.short",
|
|
132
151
|
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
133
152
|
* family — nothing is being counted in words. */
|
|
134
153
|
filtersChipMore: "search.filters.chip_more",
|
|
@@ -207,6 +226,34 @@ export const SEARCH_I18N_KEYS = {
|
|
|
207
226
|
rankingInactive: "search.ranking.inactive",
|
|
208
227
|
rankingNotes: "search.ranking.notes",
|
|
209
228
|
rankingLink: "search.ranking.link",
|
|
229
|
+
|
|
230
|
+
// ── nav (the destination's own name, not the page's heading) ─────────────
|
|
231
|
+
/**
|
|
232
|
+
* What the MENU calls the results screen.
|
|
233
|
+
*
|
|
234
|
+
* Separate from `search.results.title` on purpose. That key captions the
|
|
235
|
+
* list of matches — a heading over rows, which is why it reads "Results" —
|
|
236
|
+
* and a menu entry pointing at `/s` labelled "Results" tells a visitor
|
|
237
|
+
* nothing about where they would be going: results of what? The destination
|
|
238
|
+
* is the search itself. One key, one job; a translator asked to make
|
|
239
|
+
* "Results" work as both a page heading and a menu label has to pick which
|
|
240
|
+
* one to get wrong.
|
|
241
|
+
*/
|
|
242
|
+
navResults: "search.nav.results",
|
|
243
|
+
/** The menu name of the P2B disclosure PAGE — see {@link navResults} for
|
|
244
|
+
* why it is not `search.ranking.title`, which is the page's own heading and
|
|
245
|
+
* a whole sentence. */
|
|
246
|
+
navRanking: "search.nav.ranking",
|
|
247
|
+
/**
|
|
248
|
+
* The compact form for a phone dock — see `NavEntry.shortLabelKey`. A
|
|
249
|
+
* five-cell dock at 390px gives a destination roughly ten characters, and
|
|
250
|
+
* "Ranking disclosure" ellipsizes to a fragment there.
|
|
251
|
+
*
|
|
252
|
+
* `search.nav.results` needs no short form: it is already one word in every
|
|
253
|
+
* locale this pair ships, and declaring a short key identical to the long
|
|
254
|
+
* one only gives a translator two strings to keep in sync.
|
|
255
|
+
*/
|
|
256
|
+
navRankingShort: "search.nav.ranking.short",
|
|
210
257
|
} as const;
|
|
211
258
|
|
|
212
259
|
export type SearchI18nKey =
|
|
@@ -233,6 +280,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
233
280
|
SEARCH_I18N_KEYS.resultsCountExact,
|
|
234
281
|
SEARCH_I18N_KEYS.filtersShowCount,
|
|
235
282
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
283
|
+
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
236
284
|
];
|
|
237
285
|
|
|
238
286
|
/**
|
|
@@ -276,6 +324,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
276
324
|
"search.box.submit": "Search",
|
|
277
325
|
"search.box.clear": "Clear the search",
|
|
278
326
|
"search.box.suggestions": "Suggestions",
|
|
327
|
+
"search.box.categories": "Sections",
|
|
328
|
+
"search.box.category_count.one": "{count} listing",
|
|
329
|
+
"search.box.category_count.other": "{count} listings",
|
|
279
330
|
|
|
280
331
|
"search.sort.label": "Sort",
|
|
281
332
|
"search.sort.relevance": "Most relevant",
|
|
@@ -323,6 +374,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
323
374
|
"search.filters.dismiss": "Close the filters",
|
|
324
375
|
"search.filters.chips_label": "Filters",
|
|
325
376
|
"search.filters.all": "All filters",
|
|
377
|
+
"search.filters.short": "Filters",
|
|
326
378
|
"search.filters.chip_more": ", +{count}",
|
|
327
379
|
|
|
328
380
|
"search.category.title": "Category",
|
|
@@ -379,6 +431,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
379
431
|
"search.ranking.inactive": "Not applied: {reason}",
|
|
380
432
|
"search.ranking.notes": "Notes",
|
|
381
433
|
"search.ranking.link": "How these results are ordered",
|
|
434
|
+
|
|
435
|
+
"search.nav.results": "Search",
|
|
436
|
+
"search.nav.ranking": "Ranking disclosure",
|
|
437
|
+
"search.nav.ranking.short": "Ranking",
|
|
382
438
|
};
|
|
383
439
|
|
|
384
440
|
/**
|
package/src/i18n/ru.ts
CHANGED
|
@@ -69,6 +69,11 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
69
69
|
"search.box.submit": "Найти",
|
|
70
70
|
"search.box.clear": "Очистить запрос",
|
|
71
71
|
"search.box.suggestions": "Подсказки",
|
|
72
|
+
"search.box.categories": "Разделы",
|
|
73
|
+
"search.box.category_count.one": "{count} объявление",
|
|
74
|
+
"search.box.category_count.few": "{count} объявления",
|
|
75
|
+
"search.box.category_count.many": "{count} объявлений",
|
|
76
|
+
"search.box.category_count.other": "{count} объявления",
|
|
72
77
|
|
|
73
78
|
"search.sort.label": "Сортировка",
|
|
74
79
|
"search.sort.relevance": "По релевантности",
|
|
@@ -120,6 +125,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
120
125
|
"search.filters.dismiss": "Закрыть фильтры",
|
|
121
126
|
"search.filters.chips_label": "Фильтры",
|
|
122
127
|
"search.filters.all": "Все фильтры",
|
|
128
|
+
"search.filters.short": "Фильтры",
|
|
123
129
|
"search.filters.chip_more": ", +{count}",
|
|
124
130
|
|
|
125
131
|
"search.category.title": "Категория",
|
|
@@ -177,6 +183,10 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
177
183
|
"search.ranking.inactive": "Не применяется: {reason}",
|
|
178
184
|
"search.ranking.notes": "Примечания",
|
|
179
185
|
"search.ranking.link": "Как упорядочены результаты",
|
|
186
|
+
|
|
187
|
+
"search.nav.results": "Поиск",
|
|
188
|
+
"search.nav.ranking": "Порядок результатов",
|
|
189
|
+
"search.nav.ranking.short": "Порядок",
|
|
180
190
|
};
|
|
181
191
|
|
|
182
192
|
/** Register the ru bundle. Call AFTER `registerSearchI18n` so it layers over
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,12 @@
|
|
|
44
44
|
export { createSearchApi, searchQueryParams } from "./api/searchApi.js";
|
|
45
45
|
export type { SearchApi } from "./api/searchApi.js";
|
|
46
46
|
export type { Schemas } from "./api/types.js";
|
|
47
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
SEARCH_SORTS,
|
|
49
|
+
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
|
+
SUGGEST_DEGRADED_ROLLUP,
|
|
51
|
+
suggestTerms,
|
|
52
|
+
} from "./api/types.js";
|
|
48
53
|
export type {
|
|
49
54
|
FacetMeta,
|
|
50
55
|
FacetSelection,
|
|
@@ -59,6 +64,9 @@ export type {
|
|
|
59
64
|
SearchQueryState,
|
|
60
65
|
SearchRange,
|
|
61
66
|
SearchResponse,
|
|
67
|
+
SuggestAnswer,
|
|
68
|
+
SuggestCategory,
|
|
69
|
+
SuggestCategoryMatch,
|
|
62
70
|
SuggestParams,
|
|
63
71
|
SuggestResponse,
|
|
64
72
|
} from "./api/types.js";
|
|
@@ -100,7 +108,12 @@ export type {
|
|
|
100
108
|
SearchDegradationAudience,
|
|
101
109
|
} from "./state/degradations.js";
|
|
102
110
|
|
|
103
|
-
export {
|
|
111
|
+
export {
|
|
112
|
+
FACETABLE_FEATURE_TYPES,
|
|
113
|
+
buildFacetGroups,
|
|
114
|
+
facetOptionLabel,
|
|
115
|
+
isFacetableFeature,
|
|
116
|
+
} from "./state/facets.js";
|
|
104
117
|
export type {
|
|
105
118
|
BuildFacetGroupsInput,
|
|
106
119
|
FacetGroup,
|
|
@@ -157,8 +170,13 @@ export { SearchResults } from "./headless/SearchResults.js";
|
|
|
157
170
|
export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
|
|
158
171
|
export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
|
|
159
172
|
export type { FacetPanelBag } from "./headless/FacetPanel.js";
|
|
173
|
+
export { useHostFacetLabels } from "./headless/useFacetLabels.js";
|
|
174
|
+
export type {
|
|
175
|
+
FacetLabelRequest,
|
|
176
|
+
FacetLabelResolver,
|
|
177
|
+
} from "./headless/useFacetLabels.js";
|
|
160
178
|
export { useAppliedSort } from "./headless/useAppliedSort.js";
|
|
161
|
-
export { useSearchBox } from "./headless/useSearchBox.js";
|
|
179
|
+
export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
|
|
162
180
|
export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
|
|
163
181
|
export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
|
|
164
182
|
export type {
|
package/src/model/queries.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RankingResponse,
|
|
7
7
|
SearchQueryState,
|
|
8
8
|
SearchResponse,
|
|
9
|
-
|
|
9
|
+
SuggestAnswer,
|
|
10
10
|
} from "../api/types.js";
|
|
11
11
|
import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
|
|
12
12
|
import { useSearchApi } from "./context.js";
|
|
@@ -63,11 +63,12 @@ export function useSearchQuery(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
66
|
+
* What the box offers under the cursor: CATEGORIES and title prefixes.
|
|
67
67
|
*
|
|
68
68
|
* Not from a query log: stapel-search keeps none, which is a privacy decision
|
|
69
|
-
* before it is a product one (`services.suggest`). So
|
|
70
|
-
* in the catalogue
|
|
69
|
+
* before it is a product one (`services.suggest`). So both halves are what
|
|
70
|
+
* exists in the catalogue — choosing a term is always a search that has
|
|
71
|
+
* results, and choosing a category is always a section that exists.
|
|
71
72
|
*
|
|
72
73
|
* `enabled` is the debounce's partner, not its replacement: the CALLER holds a
|
|
73
74
|
* debounced prefix (see `useSearchBox`) and this hook refuses to ask about a
|
|
@@ -83,7 +84,7 @@ export function useSuggest(params: {
|
|
|
83
84
|
readonly q: string;
|
|
84
85
|
readonly limit?: number;
|
|
85
86
|
readonly enabled?: boolean;
|
|
86
|
-
}): UseQueryResult<
|
|
87
|
+
}): UseQueryResult<SuggestAnswer, StapelApiError> {
|
|
87
88
|
const api = useSearchApi();
|
|
88
89
|
const q = params.q.trim();
|
|
89
90
|
const limit = params.limit;
|
package/src/model/queryKeys.ts
CHANGED
|
@@ -32,6 +32,22 @@ export const searchQueryKeys: {
|
|
|
32
32
|
query(
|
|
33
33
|
params: SearchQueryKeyParams
|
|
34
34
|
): readonly ["search", "query", SearchQueryKeyParams];
|
|
35
|
+
/**
|
|
36
|
+
* One group's captions from the HOST's resolver — the seam that names a
|
|
37
|
+
* vocabulary-backed facet value on a server too old to caption it itself.
|
|
38
|
+
*
|
|
39
|
+
* Keyed on the slug, the values asked about and the locale. The values are
|
|
40
|
+
* SORTED by the caller before they get here: the same group arrives in
|
|
41
|
+
* count order, and count order changes on every click, so an unsorted key
|
|
42
|
+
* would re-ask the host for the same words each time a sibling facet moved.
|
|
43
|
+
* The locale is in the key because a caption is copy — one cache entry per
|
|
44
|
+
* language, never one shared between two.
|
|
45
|
+
*/
|
|
46
|
+
facetLabels(
|
|
47
|
+
slug: string,
|
|
48
|
+
values: readonly string[],
|
|
49
|
+
locale?: string
|
|
50
|
+
): readonly ["search", "facet-labels", string, readonly string[], string | null];
|
|
35
51
|
/** The P2B disclosure. `type` is optional on the endpoint; normalized to
|
|
36
52
|
* `null` so an absent type and an explicit `undefined` cannot cache twice. */
|
|
37
53
|
ranking(type?: string): readonly ["search", "ranking", string | null];
|
|
@@ -43,6 +59,13 @@ export const searchQueryKeys: {
|
|
|
43
59
|
} = {
|
|
44
60
|
all: [ROOT],
|
|
45
61
|
query: (params) => [ROOT, "query", params],
|
|
62
|
+
facetLabels: (slug, values, locale) => [
|
|
63
|
+
ROOT,
|
|
64
|
+
"facet-labels",
|
|
65
|
+
slug,
|
|
66
|
+
values,
|
|
67
|
+
locale ?? null,
|
|
68
|
+
],
|
|
46
69
|
ranking: (type) => [ROOT, "ranking", type ?? null],
|
|
47
70
|
suggest: (type, q, limit) => [ROOT, "suggest", type, q, limit ?? null],
|
|
48
71
|
};
|