@stapel/search-react 0.10.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 +160 -0
- package/MODULE.md +111 -0
- package/README.md +42 -0
- package/dist/api/generated/schema.d.ts +76 -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/DegradationNotice.d.ts +22 -13
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +7 -8
- package/dist/default/DegradationNotice.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 +29 -16
- 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 +166 -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/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +49 -5
- package/dist/default/RangeFilterRow.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 +26 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +16 -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 +8 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +48 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +58 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +10 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- 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/degradations.d.ts +32 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +33 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +64 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +134 -9
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +45 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +55 -14
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +27 -2
- package/nav-manifest.json +4 -3
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +76 -6
- package/src/api/searchApi.ts +13 -6
- package/src/api/types.ts +115 -1
- package/src/default/DegradationNotice.tsx +30 -20
- package/src/default/FacetPanelPane.tsx +55 -25
- package/src/default/FilterChips.tsx +290 -38
- package/src/default/LocationSummaryLine.tsx +6 -1
- package/src/default/RangeFilterRow.tsx +61 -3
- 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 +44 -1
- package/src/headless/useFacetLabels.ts +172 -0
- package/src/headless/useSearchBox.ts +132 -5
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +60 -0
- package/src/i18n/ru.ts +11 -0
- package/src/index.ts +28 -4
- package/src/model/queries.ts +6 -5
- package/src/model/queryKeys.ts +23 -0
- package/src/nav/manifest.ts +24 -2
- package/src/state/degradations.ts +56 -0
- package/src/state/facets.ts +181 -9
- package/src/state/ranges.ts +85 -15
|
@@ -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",
|
|
@@ -72,6 +75,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
72
75
|
"search.facets.not_counted": "sin contar",
|
|
73
76
|
"search.facets.drill_down_hint":
|
|
74
77
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
78
|
+
"search.range.price": "Precio",
|
|
75
79
|
"search.facets.range_from": "Desde",
|
|
76
80
|
"search.facets.range_to": "Hasta",
|
|
77
81
|
"search.facets.range_apply": "Aplicar",
|
|
@@ -92,6 +96,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
92
96
|
"search.filters.dismiss": "Cerrar los filtros",
|
|
93
97
|
"search.filters.chips_label": "Filtros",
|
|
94
98
|
"search.filters.all": "Todos los filtros",
|
|
99
|
+
"search.filters.short": "Filtros",
|
|
95
100
|
"search.filters.chip_more": ", +{count}",
|
|
96
101
|
|
|
97
102
|
"search.category.title": "Categoría",
|
|
@@ -149,6 +154,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
149
154
|
"search.ranking.inactive": "No aplicado: {reason}",
|
|
150
155
|
"search.ranking.notes": "Notas",
|
|
151
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",
|
|
152
161
|
};
|
|
153
162
|
|
|
154
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",
|
|
@@ -103,6 +119,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
103
119
|
facetsRangeToAria: "search.facets.range_to_aria",
|
|
104
120
|
/** The reason "Apply" is off: the range reads backwards. */
|
|
105
121
|
facetsRangeInvalid: "search.facets.range_invalid",
|
|
122
|
+
/** A CORE range axis has no FeatureDef and so no `name` to translate.
|
|
123
|
+
* `search.range.<slug>`; `price` is the one stapel-search ships. */
|
|
124
|
+
rangePrice: "search.range.price",
|
|
106
125
|
/** The fold on a long facet group: "Show all (46)". The number is in
|
|
107
126
|
* brackets and not a counted noun, so this is one message and not a plural
|
|
108
127
|
* family — "(1)" never renders, because a group is only folded when the
|
|
@@ -126,6 +145,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
126
145
|
/** The leading, icon-only chip: the whole panel. Icon-only means the name
|
|
127
146
|
* exists ONLY here, so this key is the control's entire accessibility. */
|
|
128
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",
|
|
129
151
|
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
130
152
|
* family — nothing is being counted in words. */
|
|
131
153
|
filtersChipMore: "search.filters.chip_more",
|
|
@@ -204,6 +226,34 @@ export const SEARCH_I18N_KEYS = {
|
|
|
204
226
|
rankingInactive: "search.ranking.inactive",
|
|
205
227
|
rankingNotes: "search.ranking.notes",
|
|
206
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",
|
|
207
257
|
} as const;
|
|
208
258
|
|
|
209
259
|
export type SearchI18nKey =
|
|
@@ -230,6 +280,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
230
280
|
SEARCH_I18N_KEYS.resultsCountExact,
|
|
231
281
|
SEARCH_I18N_KEYS.filtersShowCount,
|
|
232
282
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
283
|
+
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
233
284
|
];
|
|
234
285
|
|
|
235
286
|
/**
|
|
@@ -273,6 +324,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
273
324
|
"search.box.submit": "Search",
|
|
274
325
|
"search.box.clear": "Clear the search",
|
|
275
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",
|
|
276
330
|
|
|
277
331
|
"search.sort.label": "Sort",
|
|
278
332
|
"search.sort.relevance": "Most relevant",
|
|
@@ -299,6 +353,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
299
353
|
"search.facets.not_counted": "not counted",
|
|
300
354
|
"search.facets.drill_down_hint":
|
|
301
355
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
|
356
|
+
"search.range.price": "Price",
|
|
302
357
|
"search.facets.range_from": "From",
|
|
303
358
|
"search.facets.range_to": "To",
|
|
304
359
|
"search.facets.range_apply": "Apply",
|
|
@@ -319,6 +374,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
319
374
|
"search.filters.dismiss": "Close the filters",
|
|
320
375
|
"search.filters.chips_label": "Filters",
|
|
321
376
|
"search.filters.all": "All filters",
|
|
377
|
+
"search.filters.short": "Filters",
|
|
322
378
|
"search.filters.chip_more": ", +{count}",
|
|
323
379
|
|
|
324
380
|
"search.category.title": "Category",
|
|
@@ -375,6 +431,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
375
431
|
"search.ranking.inactive": "Not applied: {reason}",
|
|
376
432
|
"search.ranking.notes": "Notes",
|
|
377
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",
|
|
378
438
|
};
|
|
379
439
|
|
|
380
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": "По релевантности",
|
|
@@ -95,6 +100,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
95
100
|
"search.facets.not_counted": "не посчитано",
|
|
96
101
|
"search.facets.drill_down_hint":
|
|
97
102
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
|
103
|
+
"search.range.price": "Цена",
|
|
98
104
|
"search.facets.range_from": "От",
|
|
99
105
|
"search.facets.range_to": "До",
|
|
100
106
|
"search.facets.range_apply": "Применить",
|
|
@@ -119,6 +125,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
119
125
|
"search.filters.dismiss": "Закрыть фильтры",
|
|
120
126
|
"search.filters.chips_label": "Фильтры",
|
|
121
127
|
"search.filters.all": "Все фильтры",
|
|
128
|
+
"search.filters.short": "Фильтры",
|
|
122
129
|
"search.filters.chip_more": ", +{count}",
|
|
123
130
|
|
|
124
131
|
"search.category.title": "Категория",
|
|
@@ -176,6 +183,10 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
176
183
|
"search.ranking.inactive": "Не применяется: {reason}",
|
|
177
184
|
"search.ranking.notes": "Примечания",
|
|
178
185
|
"search.ranking.link": "Как упорядочены результаты",
|
|
186
|
+
|
|
187
|
+
"search.nav.results": "Поиск",
|
|
188
|
+
"search.nav.ranking": "Порядок результатов",
|
|
189
|
+
"search.nav.ranking.short": "Порядок",
|
|
179
190
|
};
|
|
180
191
|
|
|
181
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";
|
|
@@ -89,13 +97,23 @@ export type {
|
|
|
89
97
|
export {
|
|
90
98
|
countIsEstimate,
|
|
91
99
|
countKind,
|
|
100
|
+
degradationAudience,
|
|
92
101
|
degradationMessageKey,
|
|
93
102
|
isCountNuanceOnly,
|
|
94
103
|
parseDegradations,
|
|
104
|
+
readerFacing,
|
|
105
|
+
} from "./state/degradations.js";
|
|
106
|
+
export type {
|
|
107
|
+
SearchCountKind,
|
|
108
|
+
SearchDegradationAudience,
|
|
95
109
|
} from "./state/degradations.js";
|
|
96
|
-
export type { SearchCountKind } from "./state/degradations.js";
|
|
97
110
|
|
|
98
|
-
export {
|
|
111
|
+
export {
|
|
112
|
+
FACETABLE_FEATURE_TYPES,
|
|
113
|
+
buildFacetGroups,
|
|
114
|
+
facetOptionLabel,
|
|
115
|
+
isFacetableFeature,
|
|
116
|
+
} from "./state/facets.js";
|
|
99
117
|
export type {
|
|
100
118
|
BuildFacetGroupsInput,
|
|
101
119
|
FacetGroup,
|
|
@@ -105,6 +123,7 @@ export type {
|
|
|
105
123
|
export {
|
|
106
124
|
RANGE_FEATURE_TYPES,
|
|
107
125
|
buildRangeGroups,
|
|
126
|
+
coreRangeLabelKey,
|
|
108
127
|
isRangeFeature,
|
|
109
128
|
isRangeUsable,
|
|
110
129
|
} from "./state/ranges.js";
|
|
@@ -151,8 +170,13 @@ export { SearchResults } from "./headless/SearchResults.js";
|
|
|
151
170
|
export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
|
|
152
171
|
export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
|
|
153
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";
|
|
154
178
|
export { useAppliedSort } from "./headless/useAppliedSort.js";
|
|
155
|
-
export { useSearchBox } from "./headless/useSearchBox.js";
|
|
179
|
+
export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
|
|
156
180
|
export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
|
|
157
181
|
export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
|
|
158
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;
|