@stapel/search-react 0.20.0 → 0.21.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 +88 -0
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +47 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +3 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +55 -8
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +6 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +42 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +8 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +3 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +48 -1
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +43 -17
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +12 -2
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +51 -5
- package/src/default/FacetPanelPane.tsx +36 -1
- package/src/default/LocationSummaryLine.tsx +114 -53
- package/src/default/SearchPage.tsx +5 -0
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +59 -3
- package/src/i18n/es.ts +10 -0
- package/src/i18n/keys.ts +53 -0
- package/src/i18n/ru.ts +14 -0
- package/src/index.ts +3 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +47 -1
- package/src/state/urlState.ts +72 -13
|
@@ -94,6 +94,9 @@ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
|
|
|
94
94
|
|
|
95
95
|
/** The class the row carries, for {@link locationLineCss}. */
|
|
96
96
|
export const LOCATION_LINE_CLASS = "stapel-search-location-line";
|
|
97
|
+
/** The class the ONE location control carries — the place and the offer,
|
|
98
|
+
* inside a single box. See {@link locationLineCss} for what it fixes. */
|
|
99
|
+
export const LOCATION_LINE_GROUP_CLASS = "stapel-search-location-line-group";
|
|
97
100
|
/** The class the shrinking half carries. */
|
|
98
101
|
export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
|
|
99
102
|
/** The class the truncating label carries. */
|
|
@@ -117,6 +120,7 @@ export function locationLineCss(): string {
|
|
|
117
120
|
const where = `.${LOCATION_LINE_WHERE_CLASS}`;
|
|
118
121
|
const label = `.${LOCATION_LINE_LABEL_CLASS}`;
|
|
119
122
|
const end = `.${LOCATION_LINE_END_CLASS}`;
|
|
123
|
+
const group = `.${LOCATION_LINE_GROUP_CLASS}`;
|
|
120
124
|
return [
|
|
121
125
|
// `min-width:0` on the row too: a flex item's default `min-width:auto`
|
|
122
126
|
// is what makes a nested flex row refuse to shrink at all.
|
|
@@ -139,6 +143,35 @@ export function locationLineCss(): string {
|
|
|
139
143
|
`text-overflow:ellipsis;white-space:nowrap}`,
|
|
140
144
|
// The word a person is looking for never loses a pixel to a place name.
|
|
141
145
|
`${end}{flex:0 0 auto}`,
|
|
146
|
+
// ── ONE control, not two red links at opposite ends of the page ────────
|
|
147
|
+
//
|
|
148
|
+
// Measured on a live 1440px leaf: the row is 992px wide, "📍 Searching
|
|
149
|
+
// everywhere" sat at x=370 and "Near me · Within 25 km" at x=776, both
|
|
150
|
+
// drawn in the same brand red, with ~400px of air between them and
|
|
151
|
+
// nothing — no border, no ground, no heading — saying they were about the
|
|
152
|
+
// same thing. Two identical-looking links at opposite ends of a strip are
|
|
153
|
+
// read as two unrelated controls, which is what they looked like.
|
|
154
|
+
//
|
|
155
|
+
// So the place and the offer share ONE box with a border and a ground,
|
|
156
|
+
// sitting together at the leading edge, with a hairline between them: the
|
|
157
|
+
// eye gets a single object that says where this search is looking and
|
|
158
|
+
// offers the one move that would change it. The border comes from the
|
|
159
|
+
// role token, so it is right in both themes by construction.
|
|
160
|
+
// `min-inline-size:0` explicitly, and `flex-shrink` on the box rather
|
|
161
|
+
// than on its contents: a flex item's `min-width:auto` refuses to go
|
|
162
|
+
// below its own content, so without this the group pushed the filters
|
|
163
|
+
// door off the end of a 390px row instead of letting the place name give
|
|
164
|
+
// way — which is the half that can.
|
|
165
|
+
`${group}{display:flex;align-items:stretch;flex:0 1 auto;min-inline-size:0;` +
|
|
166
|
+
`border:1px solid ${cssVar("border")};border-radius:${String(radii.md)}px;` +
|
|
167
|
+
`background:${cssVar("surface-raised")};overflow:hidden}`,
|
|
168
|
+
// The two halves are separated by the box's own line, never by a gap: a
|
|
169
|
+
// gap inside a bordered group reads as two groups again.
|
|
170
|
+
`${group}>*+*{border-inline-start:1px solid ${cssVar("border")}}`,
|
|
171
|
+
`${group}>*{border-radius:0}`,
|
|
172
|
+
// Both halves pay for their own breathing room; the antd Button's inline
|
|
173
|
+
// padding is zeroed above so the box, not the button, sets the measure.
|
|
174
|
+
`${group}>*{padding-inline:${String(spacing[2])}px}`,
|
|
142
175
|
].join("");
|
|
143
176
|
}
|
|
144
177
|
|
|
@@ -149,7 +182,9 @@ const ROW: CSSProperties = { width: "100%" };
|
|
|
149
182
|
/** The shrinking half. The flex rules it needs live in the hoisted sheet
|
|
150
183
|
* (they have to reach antd's own wrapper span); this is the chrome. */
|
|
151
184
|
const LOCATION: CSSProperties = {
|
|
152
|
-
paddingInline:
|
|
185
|
+
// No `paddingInline` here: the group's sheet sets it for both halves, and
|
|
186
|
+
// an inline style would beat the sheet and leave one half flush against the
|
|
187
|
+
// border while the other breathed.
|
|
153
188
|
textAlign: "start",
|
|
154
189
|
};
|
|
155
190
|
|
|
@@ -159,7 +194,7 @@ const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
|
|
|
159
194
|
|
|
160
195
|
/** The offer. It never shrinks: it is three words and a number, and half of
|
|
161
196
|
* "Near me" is not an offer. */
|
|
162
|
-
const OFFER: CSSProperties = { flex: "0 0 auto"
|
|
197
|
+
const OFFER: CSSProperties = { flex: "0 0 auto" };
|
|
163
198
|
|
|
164
199
|
/**
|
|
165
200
|
* The count, IN the flow.
|
|
@@ -239,7 +274,8 @@ export function LocationSummaryLine(
|
|
|
239
274
|
props: LocationSummaryLineProps
|
|
240
275
|
): ReactElement {
|
|
241
276
|
const t = useT();
|
|
242
|
-
const { state, activeFilters, geoOffer, acceptGeoOffer } =
|
|
277
|
+
const { state, activeFilters, geoOffer, geoIsOffer, acceptGeoOffer } =
|
|
278
|
+
useSearchState();
|
|
243
279
|
const [open, setOpen] = useState(false);
|
|
244
280
|
const geo = state.geo;
|
|
245
281
|
|
|
@@ -257,13 +293,25 @@ export function LocationSummaryLine(
|
|
|
257
293
|
// the question), so this row never shows an offer beside a place.
|
|
258
294
|
const offerRadius =
|
|
259
295
|
geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
|
|
260
|
-
? t(SEARCH_I18N_KEYS.
|
|
296
|
+
? t(SEARCH_I18N_KEYS.geoRadiusKmShort, { km: geoOffer.radiusKm })
|
|
261
297
|
: undefined;
|
|
262
298
|
|
|
299
|
+
// What the line CALLS the place it is looking at.
|
|
300
|
+
//
|
|
301
|
+
// The host's name wins — it is the only one that can be checked by a reader
|
|
302
|
+
// — then the fact the provider holds, and only then the shape of the
|
|
303
|
+
// constraint. The middle arm is D184's second half: pressing "Near me"
|
|
304
|
+
// turned this line into "A chosen place on the map" for a person who had
|
|
305
|
+
// never opened a map. `geoIsOffer` is the provider reporting how the search
|
|
306
|
+
// came to be here, rather than this file inferring it from three numbers
|
|
307
|
+
// that look the same whatever produced them.
|
|
263
308
|
const where: ReactNode =
|
|
264
309
|
geo === undefined
|
|
265
310
|
? t(SEARCH_I18N_KEYS.geoEverywhere)
|
|
266
|
-
: (props.geoLabel ??
|
|
311
|
+
: (props.geoLabel ??
|
|
312
|
+
(geoIsOffer
|
|
313
|
+
? t(SEARCH_I18N_KEYS.geoNearYou)
|
|
314
|
+
: geoSummaryFallback(geo, t)));
|
|
267
315
|
|
|
268
316
|
return (
|
|
269
317
|
<>
|
|
@@ -279,60 +327,73 @@ export function LocationSummaryLine(
|
|
|
279
327
|
data-testid="search-location-summary"
|
|
280
328
|
data-geo={geo === undefined ? "off" : "on"}
|
|
281
329
|
>
|
|
282
|
-
{/*
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
style={LOCATION}
|
|
289
|
-
className={LOCATION_LINE_WHERE_CLASS}
|
|
290
|
-
data-testid="search-location-open"
|
|
291
|
-
data-analytics="none"
|
|
292
|
-
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
293
|
-
onClick={() => {
|
|
294
|
-
setOpen(true);
|
|
295
|
-
}}
|
|
330
|
+
{/* ONE control: where this search is looking, and — while the
|
|
331
|
+
question is still open — the one move that would change it. See
|
|
332
|
+
`locationLineCss` for the measurement that made them one box. */}
|
|
333
|
+
<div
|
|
334
|
+
className={LOCATION_LINE_GROUP_CLASS}
|
|
335
|
+
data-testid="search-location-group"
|
|
296
336
|
>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
className={LOCATION_LINE_LABEL_CLASS}
|
|
302
|
-
data-testid="search-location-label"
|
|
303
|
-
>
|
|
304
|
-
{where}
|
|
305
|
-
{radius !== undefined && (
|
|
306
|
-
<span data-testid="search-location-radius">
|
|
307
|
-
{" · "}
|
|
308
|
-
{radius}
|
|
309
|
-
</span>
|
|
310
|
-
)}
|
|
311
|
-
</span>
|
|
312
|
-
</Button>
|
|
337
|
+
{/* The glyph is rendered as a CHILD rather than through antd's
|
|
338
|
+
`icon` prop: the icon slot sits outside the wrapper span, so the
|
|
339
|
+
label beside it could not be given a min-width of its own — and
|
|
340
|
+
a label that cannot shrink is a label that overflows.
|
|
313
341
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{geoOffer !== undefined && (
|
|
342
|
+
`type="text"`, not `type="link"`: this half is a STATEMENT about
|
|
343
|
+
where the search is looking, and painting it the brand colour
|
|
344
|
+
made it indistinguishable from the offer beside it. The offer
|
|
345
|
+
keeps the colour, because the offer is the action. */}
|
|
319
346
|
<Button
|
|
320
|
-
type="
|
|
321
|
-
style={
|
|
322
|
-
|
|
347
|
+
type="text"
|
|
348
|
+
style={LOCATION}
|
|
349
|
+
className={LOCATION_LINE_WHERE_CLASS}
|
|
350
|
+
data-testid="search-location-open"
|
|
323
351
|
data-analytics="none"
|
|
324
|
-
data-analytics-reason="
|
|
325
|
-
onClick={
|
|
352
|
+
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
353
|
+
onClick={() => {
|
|
354
|
+
setOpen(true);
|
|
355
|
+
}}
|
|
326
356
|
>
|
|
327
|
-
{
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
357
|
+
<span style={PIN}>
|
|
358
|
+
<PinGlyph />
|
|
359
|
+
</span>
|
|
360
|
+
<span
|
|
361
|
+
className={LOCATION_LINE_LABEL_CLASS}
|
|
362
|
+
data-testid="search-location-label"
|
|
363
|
+
>
|
|
364
|
+
{where}
|
|
365
|
+
{radius !== undefined && (
|
|
366
|
+
<span data-testid="search-location-radius">
|
|
367
|
+
{" · "}
|
|
368
|
+
{radius}
|
|
369
|
+
</span>
|
|
370
|
+
)}
|
|
371
|
+
</span>
|
|
334
372
|
</Button>
|
|
335
|
-
|
|
373
|
+
|
|
374
|
+
{/* The offer, and nothing is applied until it is pressed. Drawn only
|
|
375
|
+
when the host has a position to offer AND the search carries no
|
|
376
|
+
location of its own — the provider enforces the second half, so
|
|
377
|
+
this is one condition, not two that could disagree. */}
|
|
378
|
+
{geoOffer !== undefined && (
|
|
379
|
+
<Button
|
|
380
|
+
type="link"
|
|
381
|
+
style={OFFER}
|
|
382
|
+
data-testid="search-location-offer"
|
|
383
|
+
data-analytics="none"
|
|
384
|
+
data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
|
|
385
|
+
onClick={acceptGeoOffer}
|
|
386
|
+
>
|
|
387
|
+
{t(SEARCH_I18N_KEYS.geoNearMe)}
|
|
388
|
+
{offerRadius !== undefined && (
|
|
389
|
+
<span data-testid="search-location-offer-radius">
|
|
390
|
+
{" · "}
|
|
391
|
+
{offerRadius}
|
|
392
|
+
</span>
|
|
393
|
+
)}
|
|
394
|
+
</Button>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
336
397
|
|
|
337
398
|
{/* "Filters", not "All filters": this end of the row shares 390px
|
|
338
399
|
with a place name that can run to fifteen characters, and the word
|
|
@@ -426,6 +426,11 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
426
426
|
const filtersEmpty =
|
|
427
427
|
facets.state.status === "ready" &&
|
|
428
428
|
facets.state.data.length === 0 &&
|
|
429
|
+
// Zero groups is not zero filters: `withheld` names groups the server
|
|
430
|
+
// counted and held back for covering too little of the result set, and
|
|
431
|
+
// the panel's job is to say how many (D175). Skipping the column would
|
|
432
|
+
// put that sentence nowhere.
|
|
433
|
+
facets.withheld.length === 0 &&
|
|
429
434
|
facets.activeFilters === 0 &&
|
|
430
435
|
ranges.length === 0 &&
|
|
431
436
|
state.category === undefined &&
|
|
@@ -2,9 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { loadStateFromQuery, mapLoad, useT } from "@stapel/core";
|
|
3
3
|
import type { LoadState } from "@stapel/core";
|
|
4
4
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
FacetCategoryCount,
|
|
7
|
+
FacetMeta,
|
|
8
|
+
FacetWithheldGroup,
|
|
9
|
+
SearchRange,
|
|
10
|
+
} from "../api/types.js";
|
|
6
11
|
import { useSearchQuery } from "../model/queries.js";
|
|
7
12
|
import { buildFacetGroups } from "../state/facets.js";
|
|
13
|
+
import { FACET_PLAN_EVIDENCE } from "../state/degradations.js";
|
|
8
14
|
import type { FacetGroup } from "../state/facets.js";
|
|
9
15
|
import { useHostFacetLabels } from "./useFacetLabels.js";
|
|
10
16
|
import type { FacetLabelResolver } from "./useFacetLabels.js";
|
|
@@ -51,6 +57,34 @@ export interface FacetPanelBag {
|
|
|
51
57
|
readonly currency: string | undefined;
|
|
52
58
|
/** Size of the largest counted set — the number `approximate` is about. */
|
|
53
59
|
readonly candidates: number;
|
|
60
|
+
/**
|
|
61
|
+
* Groups the counter COUNTED and then held back, because their buckets
|
|
62
|
+
* describe too little of the result set. Not empty means the panel may
|
|
63
|
+
* NOT say "this search offers no filters" — it says how many, instead.
|
|
64
|
+
*/
|
|
65
|
+
readonly withheld: readonly FacetWithheldGroup[];
|
|
66
|
+
/**
|
|
67
|
+
* The categories this answer's candidate set is made of, busiest first —
|
|
68
|
+
* the evidence the plan was drawn from, and the counted category filter a
|
|
69
|
+
* text search has no other way to offer. Empty when the plan is the
|
|
70
|
+
* queried category's own. `category` is the same slash-joined id path
|
|
71
|
+
* `SearchQueryState.category` takes.
|
|
72
|
+
*/
|
|
73
|
+
readonly categories: readonly FacetCategoryCount[];
|
|
74
|
+
/**
|
|
75
|
+
* Where the plan came from: `"category"` (the queried category's own
|
|
76
|
+
* authored schema) or `"evidence"` (the categories the candidate set
|
|
77
|
+
* actually contains).
|
|
78
|
+
*/
|
|
79
|
+
readonly plan: string;
|
|
80
|
+
/**
|
|
81
|
+
* True when the server could not work out a plan AT ALL — the engine has
|
|
82
|
+
* no `category_counts` verb (`degraded: ["facet_plan_evidence"]`). An
|
|
83
|
+
* empty panel then means "we do not know", so a skin must not print
|
|
84
|
+
* "there are no filters" over it; the degradation notice is what tells
|
|
85
|
+
* the reader.
|
|
86
|
+
*/
|
|
87
|
+
readonly planUnavailable: boolean;
|
|
54
88
|
/** Facet values + ranges + geo currently applied. */
|
|
55
89
|
readonly activeFilters: number;
|
|
56
90
|
|
|
@@ -67,6 +101,9 @@ const EMPTY_META: FacetMeta = {
|
|
|
67
101
|
skipped: [],
|
|
68
102
|
dropped_filters: [],
|
|
69
103
|
core_ranges: [],
|
|
104
|
+
plan: "category",
|
|
105
|
+
withheld: [],
|
|
106
|
+
categories: [],
|
|
70
107
|
};
|
|
71
108
|
|
|
72
109
|
/**
|
|
@@ -174,6 +211,17 @@ export function useFacetPanel(props: {
|
|
|
174
211
|
?.card?.["currency"] as string | undefined
|
|
175
212
|
: undefined,
|
|
176
213
|
candidates: meta.candidates,
|
|
214
|
+
withheld: meta.withheld ?? [],
|
|
215
|
+
categories: meta.categories ?? [],
|
|
216
|
+
plan: meta.plan ?? "category",
|
|
217
|
+
// Read off the raw list rather than through `parseDegradations`: the
|
|
218
|
+
// question is whether THIS answer supports the sentence "no filters",
|
|
219
|
+
// and the banner's own parsing is a different job on a different
|
|
220
|
+
// surface. Guarded on the READY state, never flattened — a query still
|
|
221
|
+
// in flight has not said anything about its facet plan.
|
|
222
|
+
planUnavailable:
|
|
223
|
+
envelope.status === "ready" &&
|
|
224
|
+
envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
|
|
177
225
|
activeFilters,
|
|
178
226
|
toggle: toggleFilter,
|
|
179
227
|
setRange,
|
|
@@ -71,6 +71,21 @@ export interface SearchStateBag {
|
|
|
71
71
|
* Draw it as an invitation ("near me"), never as state.
|
|
72
72
|
*/
|
|
73
73
|
readonly geoOffer: SearchGeo | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the location this search is USING is the one the host offered —
|
|
76
|
+
* i.e. the visitor pressed "near me" (or followed a link built by somebody
|
|
77
|
+
* who did) and has not moved the pin since.
|
|
78
|
+
*
|
|
79
|
+
* A fact about provenance, and only the provider holds it: `state.geo` is a
|
|
80
|
+
* centre and a radius, and every way of arriving at a centre produces the
|
|
81
|
+
* same three numbers. Without it a summary line has to guess, and the guess
|
|
82
|
+
* it shipped was "a chosen place on the map" — said to a person who had
|
|
83
|
+
* pressed a button and never opened a map.
|
|
84
|
+
*
|
|
85
|
+
* `false` whenever there is no offer to compare against, which includes
|
|
86
|
+
* every search on a host that offers none.
|
|
87
|
+
*/
|
|
88
|
+
readonly geoIsOffer: boolean;
|
|
74
89
|
|
|
75
90
|
setText(q: string): void;
|
|
76
91
|
setSort(sort: string | null): void;
|
|
@@ -92,6 +107,29 @@ export interface SearchStateBag {
|
|
|
92
107
|
patch(patch: SearchStatePatch): void;
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How close two coordinates have to be to be the same place, in degrees.
|
|
112
|
+
*
|
|
113
|
+
* The applied location makes a round trip through the query string, so the
|
|
114
|
+
* numbers that come back are the ones `String(lat)` produced rather than the
|
|
115
|
+
* float the device handed over. 1e-6 degrees is ~11cm — far below any
|
|
116
|
+
* position a browser reports and far above any rounding the codec introduces.
|
|
117
|
+
*/
|
|
118
|
+
const SAME_PLACE_EPSILON = 1e-6;
|
|
119
|
+
|
|
120
|
+
/** Is the applied location the offered one? See {@link SearchStateBag.geoIsOffer}. */
|
|
121
|
+
function sameCenter(
|
|
122
|
+
applied: SearchGeo | undefined,
|
|
123
|
+
offered: SearchGeo | undefined
|
|
124
|
+
): boolean {
|
|
125
|
+
if (applied === undefined || offered === undefined) return false;
|
|
126
|
+
if (applied.kind !== "center" || offered.kind !== "center") return false;
|
|
127
|
+
return (
|
|
128
|
+
Math.abs(applied.lat - offered.lat) < SAME_PLACE_EPSILON &&
|
|
129
|
+
Math.abs(applied.lon - offered.lon) < SAME_PLACE_EPSILON
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
96
134
|
|
|
97
135
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
@@ -178,6 +216,23 @@ export function SearchStateProvider(
|
|
|
178
216
|
|
|
179
217
|
const bag = useMemo<SearchStateBag>(() => {
|
|
180
218
|
const state = parsed.state;
|
|
219
|
+
/**
|
|
220
|
+
* The offer, carrying the radius the URL already asked for.
|
|
221
|
+
*
|
|
222
|
+
* `?radius_km=300` with no `lat`/`lon` narrows nothing — there is no
|
|
223
|
+
* point to measure from — but it IS a number the person typed, and the
|
|
224
|
+
* offer used to ignore it twice over: the button advertised the host's
|
|
225
|
+
* own 25km, and pressing it wrote 25 into the address over the 300 that
|
|
226
|
+
* was already there. Three things now agree, which is the whole of the
|
|
227
|
+
* fix: what the link asked for, what the button says, and what pressing
|
|
228
|
+
* it does. A bbox offer is left alone — a box has no radius to carry.
|
|
229
|
+
*/
|
|
230
|
+
const offer: SearchGeo | undefined =
|
|
231
|
+
geoOffer !== undefined &&
|
|
232
|
+
geoOffer.kind === "center" &&
|
|
233
|
+
parsed.orphanRadiusKm !== undefined
|
|
234
|
+
? { ...geoOffer, radiusKm: parsed.orphanRadiusKm }
|
|
235
|
+
: geoOffer;
|
|
181
236
|
const apply = (
|
|
182
237
|
next: SearchQueryState,
|
|
183
238
|
options?: { readonly replace?: boolean }
|
|
@@ -193,7 +248,8 @@ export function SearchStateProvider(
|
|
|
193
248
|
// — there is nothing left to offer, and a control that kept drawing
|
|
194
249
|
// "near me" beside an applied location would be inviting a person to
|
|
195
250
|
// re-answer a question they can already see the answer to.
|
|
196
|
-
geoOffer: state.geo === undefined ?
|
|
251
|
+
geoOffer: state.geo === undefined ? offer : undefined,
|
|
252
|
+
geoIsOffer: sameCenter(state.geo, offer),
|
|
197
253
|
|
|
198
254
|
// Typing replaces rather than pushes: one history entry per letter
|
|
199
255
|
// would make Back useless, which is the control the spec's acceptance
|
|
@@ -209,10 +265,10 @@ export function SearchStateProvider(
|
|
|
209
265
|
apply(patchSearchState(state, { geo }));
|
|
210
266
|
},
|
|
211
267
|
acceptGeoOffer: () => {
|
|
212
|
-
if (
|
|
268
|
+
if (offer === undefined || state.geo !== undefined) return;
|
|
213
269
|
// A PUSH, like any other filter the person applies: Back takes the
|
|
214
270
|
// narrowing off again, which is the same promise every chip makes.
|
|
215
|
-
apply(patchSearchState(state, { geo:
|
|
271
|
+
apply(patchSearchState(state, { geo: offer }));
|
|
216
272
|
},
|
|
217
273
|
// A page size is a preference, not a step through the results.
|
|
218
274
|
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
package/src/i18n/es.ts
CHANGED
|
@@ -75,6 +75,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
75
75
|
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
76
76
|
"search.facets.skipped":
|
|
77
77
|
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
78
|
+
"search.facets.withheld.one":
|
|
79
|
+
"{count} filtro se aplica a muy pocos de estos resultados",
|
|
80
|
+
"search.facets.withheld.other":
|
|
81
|
+
"{count} filtros se aplican a muy pocos de estos resultados",
|
|
78
82
|
"search.facets.not_counted": "sin contar",
|
|
79
83
|
"search.facets.drill_down_hint":
|
|
80
84
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
@@ -125,12 +129,14 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
125
129
|
|
|
126
130
|
"search.geo.title": "Ubicación",
|
|
127
131
|
"search.geo.radius_km": "A menos de {km} km",
|
|
132
|
+
"search.geo.radius_km_short": "{km} km",
|
|
128
133
|
"search.geo.radius_label": "Radio, km",
|
|
129
134
|
"search.geo.clear": "En cualquier lugar",
|
|
130
135
|
"search.geo.near_me": "Cerca de mí",
|
|
131
136
|
"search.geo.everywhere": "Buscando en todas partes",
|
|
132
137
|
"search.geo.box": "Dentro del área mostrada",
|
|
133
138
|
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
139
|
+
"search.geo.near_you": "Cerca de ti",
|
|
134
140
|
|
|
135
141
|
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
136
142
|
"search.url.issue.not_a_number":
|
|
@@ -141,6 +147,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
141
147
|
"el área del mapa de este enlace está incompleta, así que se ignoró",
|
|
142
148
|
"search.url.issue.range_malformed":
|
|
143
149
|
"al rango «{param}» de este enlace le faltan números, así que se ignoró",
|
|
150
|
+
"search.url.issue.radius_without_place":
|
|
151
|
+
"este enlace pide un radio pero no nombra ningún lugar, así que aún no se acota nada — elige un lugar y se aplicará ese mismo radio",
|
|
144
152
|
|
|
145
153
|
"search.degraded.title": "Lo que esta búsqueda no pudo hacer",
|
|
146
154
|
"search.degraded.typo_tolerance":
|
|
@@ -151,6 +159,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
151
159
|
"search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
|
|
152
160
|
"search.degraded.category_rollup":
|
|
153
161
|
"Pueden faltar subcategorías en estos resultados: el servicio de categorías no respondió.",
|
|
162
|
+
"search.degraded.facet_plan_evidence":
|
|
163
|
+
"No pudimos determinar qué filtros encajan con estos resultados, así que puede haber más de los que muestra el panel.",
|
|
154
164
|
"search.degraded.scorer":
|
|
155
165
|
"El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
|
|
156
166
|
"search.degraded.unknown":
|
package/src/i18n/keys.ts
CHANGED
|
@@ -116,6 +116,17 @@ export const SEARCH_I18N_KEYS = {
|
|
|
116
116
|
facetsClearAll: "search.facets.clear_all",
|
|
117
117
|
facetsApproximate: "search.facets.approximate",
|
|
118
118
|
facetsSkipped: "search.facets.skipped",
|
|
119
|
+
/**
|
|
120
|
+
* "N filters apply to too few of these results" — the sentence that
|
|
121
|
+
* REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
|
|
122
|
+
* empty. A PLURAL FAMILY: it counts a noun in words.
|
|
123
|
+
*
|
|
124
|
+
* D175: a branch category holding 46 phones that all carry a manufacturer
|
|
125
|
+
* printed "this search offers no filters". The groups were counted and
|
|
126
|
+
* then withheld for covering too little of the set — so "there are none"
|
|
127
|
+
* was false, and the honest sentence is this one, with the number in it.
|
|
128
|
+
*/
|
|
129
|
+
facetsWithheld: "search.facets.withheld",
|
|
119
130
|
/** Shown INSTEAD of a count for a slug the server did not count. */
|
|
120
131
|
facetsNotCounted: "search.facets.not_counted",
|
|
121
132
|
facetsDrillDownHint: "search.facets.drill_down_hint",
|
|
@@ -217,6 +228,19 @@ export const SEARCH_I18N_KEYS = {
|
|
|
217
228
|
// ── geo ──────────────────────────────────────────────────────────────────
|
|
218
229
|
geoTitle: "search.geo.title",
|
|
219
230
|
geoRadiusKm: "search.geo.radius_km",
|
|
231
|
+
/**
|
|
232
|
+
* The same number, said in the fewest words that still say it — for the
|
|
233
|
+
* OFFER, where the sentence shares a 390px line with a place name and the
|
|
234
|
+
* word "Filters".
|
|
235
|
+
*
|
|
236
|
+
* Measured at 390: "Near me · Within 25 km" made the control 272px wide
|
|
237
|
+
* against 231px of room, so the offer was cut mid-word by the group's own
|
|
238
|
+
* clip and the filters door overlapped it by 52px. Dropping the number
|
|
239
|
+
* instead was not an option — a button that says only "near me" asks a
|
|
240
|
+
* person to accept a radius they cannot see, which is the whole reason the
|
|
241
|
+
* offer states one. So the PREPOSITION goes and the number stays.
|
|
242
|
+
*/
|
|
243
|
+
geoRadiusKmShort: "search.geo.radius_km_short",
|
|
220
244
|
geoRadiusLabel: "search.geo.radius_label",
|
|
221
245
|
geoClear: "search.geo.clear",
|
|
222
246
|
geoNearMe: "search.geo.near_me",
|
|
@@ -243,6 +267,18 @@ export const SEARCH_I18N_KEYS = {
|
|
|
243
267
|
* `geoLabel` and this sentence never appears.
|
|
244
268
|
*/
|
|
245
269
|
geoChosenPlace: "search.geo.chosen_place",
|
|
270
|
+
/**
|
|
271
|
+
* What the line says when the applied location IS the position the host
|
|
272
|
+
* offered — the visitor pressed "Near me" and nothing else has moved since.
|
|
273
|
+
*
|
|
274
|
+
* Measured on a live leaf: pressing the offer turned the line into "A chosen
|
|
275
|
+
* place on the map", to a person who had never opened a map. The sentence
|
|
276
|
+
* was not wrong about the mechanism (a centre with a radius is what a map
|
|
277
|
+
* pick produces); it was wrong about the only thing the reader can check —
|
|
278
|
+
* how the search came to be looking there. The provider reports the fact
|
|
279
|
+
* (`geoIsOffer`) rather than this file guessing at it.
|
|
280
|
+
*/
|
|
281
|
+
geoNearYou: "search.geo.near_you",
|
|
246
282
|
|
|
247
283
|
// ── the URL that could not be read ───────────────────────────────────────
|
|
248
284
|
urlIssuesTitle: "search.url.issues_title",
|
|
@@ -250,6 +286,8 @@ export const SEARCH_I18N_KEYS = {
|
|
|
250
286
|
urlIssueGeoIncomplete: "search.url.issue.geo_incomplete",
|
|
251
287
|
urlIssueBboxMalformed: "search.url.issue.bbox_malformed",
|
|
252
288
|
urlIssueRangeMalformed: "search.url.issue.range_malformed",
|
|
289
|
+
/** A radius with no point to measure it from — see `urlState.ts`. */
|
|
290
|
+
urlIssueRadiusWithoutPlace: "search.url.issue.radius_without_place",
|
|
253
291
|
|
|
254
292
|
// ── degradations (the envelope's `degraded[]`) ───────────────────────────
|
|
255
293
|
degradedTitle: "search.degraded.title",
|
|
@@ -258,6 +296,10 @@ export const SEARCH_I18N_KEYS = {
|
|
|
258
296
|
degradedExactTotal: "search.degraded.exact_total",
|
|
259
297
|
degradedExactFacetCounts: "search.degraded.exact_facet_counts",
|
|
260
298
|
degradedCategoryRollup: "search.degraded.category_rollup",
|
|
299
|
+
/** The engine cannot say which categories the results are in, so no facet
|
|
300
|
+
* plan could be drawn from them — a READER-facing sentence, because the
|
|
301
|
+
* consequence is a filter panel that is thinner than the catalogue. */
|
|
302
|
+
degradedFacetPlanEvidence: "search.degraded.facet_plan_evidence",
|
|
261
303
|
degradedScorer: "search.degraded.scorer",
|
|
262
304
|
degradedUnknown: "search.degraded.unknown",
|
|
263
305
|
|
|
@@ -329,6 +371,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
329
371
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
330
372
|
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
331
373
|
SEARCH_I18N_KEYS.facetsMatchCount,
|
|
374
|
+
SEARCH_I18N_KEYS.facetsWithheld,
|
|
332
375
|
];
|
|
333
376
|
|
|
334
377
|
/**
|
|
@@ -401,6 +444,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
401
444
|
"Counts are approximate — there were too many candidates to count them all.",
|
|
402
445
|
"search.facets.skipped":
|
|
403
446
|
"These filters were not counted for this search: {slugs}",
|
|
447
|
+
"search.facets.withheld.one":
|
|
448
|
+
"{count} filter applies to too few of these results",
|
|
449
|
+
"search.facets.withheld.other":
|
|
450
|
+
"{count} filters apply to too few of these results",
|
|
404
451
|
"search.facets.not_counted": "not counted",
|
|
405
452
|
"search.facets.drill_down_hint":
|
|
406
453
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
|
@@ -452,11 +499,13 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
452
499
|
"search.geo.title": "Location",
|
|
453
500
|
"search.geo.near_me": "Near me",
|
|
454
501
|
"search.geo.radius_km": "Within {km} km",
|
|
502
|
+
"search.geo.radius_km_short": "{km} km",
|
|
455
503
|
"search.geo.radius_label": "Radius, km",
|
|
456
504
|
"search.geo.clear": "Anywhere",
|
|
457
505
|
"search.geo.everywhere": "Searching everywhere",
|
|
458
506
|
"search.geo.box": "Inside the shown area",
|
|
459
507
|
"search.geo.chosen_place": "A chosen place on the map",
|
|
508
|
+
"search.geo.near_you": "Near you",
|
|
460
509
|
|
|
461
510
|
"search.url.issues_title": "Part of this link could not be read",
|
|
462
511
|
"search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
|
|
@@ -466,6 +515,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
466
515
|
"the map area in this link is incomplete, so it was ignored",
|
|
467
516
|
"search.url.issue.range_malformed":
|
|
468
517
|
"the range “{param}” in this link needs two numbers, so it was ignored",
|
|
518
|
+
"search.url.issue.radius_without_place":
|
|
519
|
+
"this link asks for a radius but names no place, so nothing is narrowed yet — choose a place and this radius is the one that applies",
|
|
469
520
|
|
|
470
521
|
"search.degraded.title": "What this search could not do",
|
|
471
522
|
"search.degraded.typo_tolerance":
|
|
@@ -476,6 +527,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
476
527
|
"search.degraded.exact_facet_counts": "The filter counts are approximate.",
|
|
477
528
|
"search.degraded.category_rollup":
|
|
478
529
|
"Subcategories may be missing from these results — the category service did not answer.",
|
|
530
|
+
"search.degraded.facet_plan_evidence":
|
|
531
|
+
"We could not work out which filters fit these results, so there may be more than the panel shows.",
|
|
479
532
|
"search.degraded.scorer":
|
|
480
533
|
"The ranking parameter “{scorer}” was not applied — the search engine in use cannot evaluate it.",
|
|
481
534
|
"search.degraded.unknown":
|
package/src/i18n/ru.ts
CHANGED
|
@@ -100,6 +100,14 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
100
100
|
"Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
|
|
101
101
|
"search.facets.skipped":
|
|
102
102
|
"Эти фильтры для текущего поиска не посчитаны: {slugs}",
|
|
103
|
+
"search.facets.withheld.one":
|
|
104
|
+
"Ещё {count} фильтр подходит слишком малой части результатов",
|
|
105
|
+
"search.facets.withheld.few":
|
|
106
|
+
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
107
|
+
"search.facets.withheld.many":
|
|
108
|
+
"Ещё {count} фильтров подходят слишком малой части результатов",
|
|
109
|
+
"search.facets.withheld.other":
|
|
110
|
+
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
103
111
|
"search.facets.not_counted": "не посчитано",
|
|
104
112
|
"search.facets.drill_down_hint":
|
|
105
113
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
|
@@ -156,12 +164,14 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
156
164
|
|
|
157
165
|
"search.geo.title": "Где искать",
|
|
158
166
|
"search.geo.radius_km": "В радиусе {km} км",
|
|
167
|
+
"search.geo.radius_km_short": "{km} км",
|
|
159
168
|
"search.geo.radius_label": "Радиус, км",
|
|
160
169
|
"search.geo.clear": "Везде",
|
|
161
170
|
"search.geo.near_me": "Рядом со мной",
|
|
162
171
|
"search.geo.everywhere": "Ищем везде",
|
|
163
172
|
"search.geo.box": "В показанной области",
|
|
164
173
|
"search.geo.chosen_place": "Выбранное место на карте",
|
|
174
|
+
"search.geo.near_you": "Рядом с вами",
|
|
165
175
|
|
|
166
176
|
"search.url.issues_title": "Часть этой ссылки прочитать не удалось",
|
|
167
177
|
"search.url.issue.not_a_number":
|
|
@@ -172,6 +182,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
172
182
|
"область на карте в этой ссылке указана не полностью, поэтому она пропущена",
|
|
173
183
|
"search.url.issue.range_malformed":
|
|
174
184
|
"диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
|
|
185
|
+
"search.url.issue.radius_without_place":
|
|
186
|
+
"в ссылке задан радиус, но не задано место, поэтому пока ничего не сузилось — выберите место, и применится именно этот радиус",
|
|
175
187
|
|
|
176
188
|
"search.degraded.title": "Что этот поиск не смог сделать",
|
|
177
189
|
"search.degraded.typo_tolerance":
|
|
@@ -182,6 +194,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
182
194
|
"search.degraded.exact_facet_counts": "Счётчики фильтров приблизительные.",
|
|
183
195
|
"search.degraded.category_rollup":
|
|
184
196
|
"В выдаче могут отсутствовать подкатегории — сервис категорий не ответил.",
|
|
197
|
+
"search.degraded.facet_plan_evidence":
|
|
198
|
+
"Не удалось определить, какие фильтры подходят этим результатам, — их может быть больше, чем показано.",
|
|
185
199
|
"search.degraded.scorer":
|
|
186
200
|
"Параметр ранжирования «{scorer}» не применялся — используемый движок не умеет его считать.",
|
|
187
201
|
"search.degraded.unknown":
|
package/src/index.ts
CHANGED
|
@@ -51,8 +51,10 @@ export {
|
|
|
51
51
|
suggestTerms,
|
|
52
52
|
} from "./api/types.js";
|
|
53
53
|
export type {
|
|
54
|
+
FacetCategoryCount,
|
|
54
55
|
FacetMeta,
|
|
55
56
|
FacetSelection,
|
|
57
|
+
FacetWithheldGroup,
|
|
56
58
|
RankingResponse,
|
|
57
59
|
Scorer,
|
|
58
60
|
SearchDegradation,
|
|
@@ -95,6 +97,7 @@ export type {
|
|
|
95
97
|
} from "./state/urlState.js";
|
|
96
98
|
|
|
97
99
|
export {
|
|
100
|
+
FACET_PLAN_EVIDENCE,
|
|
98
101
|
countIsEstimate,
|
|
99
102
|
countKind,
|
|
100
103
|
degradationAudience,
|