@stapel/search-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +203 -0
- package/README.md +175 -0
- package/dist/api/generated/schema.d.ts +375 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/searchApi.d.ts +77 -0
- package/dist/api/searchApi.d.ts.map +1 -0
- package/dist/api/searchApi.js +79 -0
- package/dist/api/searchApi.js.map +1 -0
- package/dist/api/types.d.ts +131 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +20 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/DegradationNotice.d.ts +19 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -0
- package/dist/default/DegradationNotice.js +14 -0
- package/dist/default/DegradationNotice.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +26 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -0
- package/dist/default/FacetPanelPane.js +32 -0
- package/dist/default/FacetPanelPane.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +17 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
- package/dist/default/RankingDisclosurePane.js +23 -0
- package/dist/default/RankingDisclosurePane.js.map +1 -0
- package/dist/default/SearchPage.d.ts +34 -0
- package/dist/default/SearchPage.d.ts.map +1 -0
- package/dist/default/SearchPage.js +16 -0
- package/dist/default/SearchPage.js.map +1 -0
- package/dist/default/SearchResultCard.d.ts +34 -0
- package/dist/default/SearchResultCard.d.ts.map +1 -0
- package/dist/default/SearchResultCard.js +38 -0
- package/dist/default/SearchResultCard.js.map +1 -0
- package/dist/default/SearchResultsPane.d.ts +32 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -0
- package/dist/default/SearchResultsPane.js +46 -0
- package/dist/default/SearchResultsPane.js.map +1 -0
- package/dist/default/SortSelect.d.ts +22 -0
- package/dist/default/SortSelect.d.ts.map +1 -0
- package/dist/default/SortSelect.js +39 -0
- package/dist/default/SortSelect.js.map +1 -0
- package/dist/default/UrlIssueNotice.d.ts +12 -0
- package/dist/default/UrlIssueNotice.d.ts.map +1 -0
- package/dist/default/UrlIssueNotice.js +13 -0
- package/dist/default/UrlIssueNotice.js.map +1 -0
- package/dist/default/index.d.ts +40 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +34 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +61 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -0
- package/dist/headless/FacetPanel.js +57 -0
- package/dist/headless/FacetPanel.js.map +1 -0
- package/dist/headless/RankingDisclosure.d.ts +39 -0
- package/dist/headless/RankingDisclosure.d.ts.map +1 -0
- package/dist/headless/RankingDisclosure.js +28 -0
- package/dist/headless/RankingDisclosure.js.map +1 -0
- package/dist/headless/SearchProvider.d.ts +19 -0
- package/dist/headless/SearchProvider.d.ts.map +1 -0
- package/dist/headless/SearchProvider.js +15 -0
- package/dist/headless/SearchProvider.js.map +1 -0
- package/dist/headless/SearchResults.d.ts +68 -0
- package/dist/headless/SearchResults.d.ts.map +1 -0
- package/dist/headless/SearchResults.js +70 -0
- package/dist/headless/SearchResults.js.map +1 -0
- package/dist/headless/SearchStateProvider.d.ts +69 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -0
- package/dist/headless/SearchStateProvider.js +71 -0
- package/dist/headless/SearchStateProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +27 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +37 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +13 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +85 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +63 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +63 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +107 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +185 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +29 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +101 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +48 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +65 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +25 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +22 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +25 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +34 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +34 -0
- package/dist/router/index.js.map +1 -0
- package/dist/state/degradations.d.ts +30 -0
- package/dist/state/degradations.d.ts.map +1 -0
- package/dist/state/degradations.js +52 -0
- package/dist/state/degradations.js.map +1 -0
- package/dist/state/facets.d.ts +68 -0
- package/dist/state/facets.d.ts.map +1 -0
- package/dist/state/facets.js +171 -0
- package/dist/state/facets.js.map +1 -0
- package/dist/state/urlState.d.ts +143 -0
- package/dist/state/urlState.d.ts.map +1 -0
- package/dist/state/urlState.js +319 -0
- package/dist/state/urlState.js.map +1 -0
- package/llms.txt +87 -0
- package/manifest.json +652 -0
- package/nav-manifest.json +44 -0
- package/package.json +133 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +377 -0
- package/src/api/searchApi.ts +169 -0
- package/src/api/types.ts +160 -0
- package/src/default/DegradationNotice.tsx +50 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FacetPanelPane.tsx +174 -0
- package/src/default/RankingDisclosurePane.tsx +116 -0
- package/src/default/SearchPage.tsx +87 -0
- package/src/default/SearchResultCard.tsx +96 -0
- package/src/default/SearchResultsPane.tsx +173 -0
- package/src/default/SortSelect.tsx +77 -0
- package/src/default/UrlIssueNotice.tsx +37 -0
- package/src/default/index.ts +48 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/FacetPanel.tsx +113 -0
- package/src/headless/RankingDisclosure.tsx +58 -0
- package/src/headless/SearchProvider.tsx +20 -0
- package/src/headless/SearchResults.tsx +149 -0
- package/src/headless/SearchStateProvider.tsx +175 -0
- package/src/i18n/errorsMap.ts +50 -0
- package/src/i18n/es.ts +110 -0
- package/src/i18n/generated/errors.es.gen.ts +69 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +423 -0
- package/src/i18n/generated/errors.ru.gen.ts +69 -0
- package/src/i18n/keys.ts +220 -0
- package/src/i18n/ru.ts +126 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +79 -0
- package/src/model/queryKeys.ts +48 -0
- package/src/model/runtime.ts +33 -0
- package/src/nav/manifest.ts +53 -0
- package/src/router/index.ts +38 -0
- package/src/state/degradations.ts +75 -0
- package/src/state/facets.ts +219 -0
- package/src/state/urlState.ts +458 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { ModuleProvider } from "../model/context.js";
|
|
3
|
+
import type { SearchRuntime } from "../model/runtime.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides the wired {@link SearchRuntime} to every search hook and headless
|
|
7
|
+
* component below it. Bring your own visual shell — this component renders
|
|
8
|
+
* nothing of its own. (Core's `createModuleContext` provider, bound to this
|
|
9
|
+
* pair — slim wave §21/S2.)
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const runtime = createSearchRuntime({ baseUrl: "/search/api/v1/" });
|
|
13
|
+
* // give runtime.client to core's <StapelConfigProvider config={{ client }}>
|
|
14
|
+
* <SearchProvider runtime={runtime}>{app}</SearchProvider>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export const SearchProvider: (props: {
|
|
18
|
+
runtime: SearchRuntime;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}) => ReactElement = ModuleProvider;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
actionAvailable,
|
|
5
|
+
actionBlocked,
|
|
6
|
+
actionBlockedByFailure,
|
|
7
|
+
loadStateFromQuery,
|
|
8
|
+
mapLoad,
|
|
9
|
+
} from "@stapel/core";
|
|
10
|
+
import type { ActionAvailability, LoadState, StapelApiError } from "@stapel/core";
|
|
11
|
+
import type {
|
|
12
|
+
SearchDegradation,
|
|
13
|
+
SearchItem,
|
|
14
|
+
SearchResponse,
|
|
15
|
+
} from "../api/types.js";
|
|
16
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
17
|
+
import { useSearchQuery } from "../model/queries.js";
|
|
18
|
+
import { countIsEstimate, parseDegradations } from "../state/degradations.js";
|
|
19
|
+
import { useSearchState } from "./SearchStateProvider.js";
|
|
20
|
+
|
|
21
|
+
/** What the envelope says about this page, beside the rows. */
|
|
22
|
+
export interface SearchPageInfo {
|
|
23
|
+
/** Total matches — an ESTIMATE unless {@link SearchPageInfo.exactTotal}. */
|
|
24
|
+
readonly count: number;
|
|
25
|
+
readonly exactTotal: boolean;
|
|
26
|
+
/** `true` when `count` must be rendered as approximate ("≈", "about"). */
|
|
27
|
+
readonly countIsEstimate: boolean;
|
|
28
|
+
readonly hasNext: boolean;
|
|
29
|
+
readonly hasPrev: boolean;
|
|
30
|
+
/** The sort the SERVER applied — which may not be the one requested when
|
|
31
|
+
* none was requested at all (it picks relevance with text, newest without). */
|
|
32
|
+
readonly sort: string;
|
|
33
|
+
readonly backend: string;
|
|
34
|
+
readonly tookMs: number;
|
|
35
|
+
/** How many of this page's rows are promoted (DSA Art. 26 marking). */
|
|
36
|
+
readonly promotedCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The bag `<SearchResults>` hands its render prop. */
|
|
40
|
+
export interface SearchResultsBag {
|
|
41
|
+
/**
|
|
42
|
+
* The page's rows as a state a skin cannot flatten. Render with core's
|
|
43
|
+
* `matchList` — its four arms are what keeps "nothing found" a sentence
|
|
44
|
+
* that can only be said about a search that actually ran. The 2026-08-09
|
|
45
|
+
* incident was this exact substitution.
|
|
46
|
+
*/
|
|
47
|
+
readonly state: LoadState<readonly SearchItem[]>;
|
|
48
|
+
/** The whole envelope, for a skin that needs a field this bag does not
|
|
49
|
+
* lift. Same load discipline. */
|
|
50
|
+
readonly envelope: LoadState<SearchResponse>;
|
|
51
|
+
/** `null` until the page has landed. */
|
|
52
|
+
readonly page: SearchPageInfo | null;
|
|
53
|
+
/**
|
|
54
|
+
* What the engine could not do for this query, de-duplicated. Rendered as a
|
|
55
|
+
* banner, never swallowed: a page that hides "counts are approximate" is
|
|
56
|
+
* claiming a precision the backend explicitly disclaimed.
|
|
57
|
+
*/
|
|
58
|
+
readonly degradations: readonly SearchDegradation[];
|
|
59
|
+
/** A refetch is in flight over rows already on screen. */
|
|
60
|
+
readonly isFetching: boolean;
|
|
61
|
+
/** The last refusal, for a skin that renders the code's own sentence.
|
|
62
|
+
* `error.400.search_window_exceeded` is "narrow the search", not "nothing
|
|
63
|
+
* found" — the two must not share a branch. */
|
|
64
|
+
readonly error: StapelApiError | null;
|
|
65
|
+
/** Blocked WITH A REASON at the last page, never a bare disabled button. */
|
|
66
|
+
readonly next: ActionAvailability;
|
|
67
|
+
readonly prev: ActionAvailability;
|
|
68
|
+
goNext(): void;
|
|
69
|
+
goPrev(): void;
|
|
70
|
+
refetch(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Headless result page — the rows, the keyset controls, and the envelope's
|
|
75
|
+
* honesty block. Renderless: it decides nothing about how any of that looks.
|
|
76
|
+
*
|
|
77
|
+
* Pagination is KEYSET (`anchor`/`direction`), not offset: there is no page
|
|
78
|
+
* number to jump to, and depth is capped server-side by `MAX_RESULT_WINDOW`
|
|
79
|
+
* (a cursor past it answers `error.400.search_window_exceeded`). Both facts
|
|
80
|
+
* are the backend's, and both are surfaced rather than papered over.
|
|
81
|
+
*/
|
|
82
|
+
export function SearchResults(props: {
|
|
83
|
+
/** Skip the request (e.g. while the host is still resolving a category). */
|
|
84
|
+
enabled?: boolean;
|
|
85
|
+
children: (bag: SearchResultsBag) => ReactNode;
|
|
86
|
+
}): ReactNode {
|
|
87
|
+
const { state: searchState, goToAnchor } = useSearchState();
|
|
88
|
+
const query = useSearchQuery(
|
|
89
|
+
searchState,
|
|
90
|
+
props.enabled !== undefined ? { enabled: props.enabled } : undefined
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const envelope = loadStateFromQuery(query);
|
|
94
|
+
const rows = mapLoad(envelope, (data) => data.items as readonly SearchItem[]);
|
|
95
|
+
const data = envelope.status === "ready" ? envelope.data : null;
|
|
96
|
+
|
|
97
|
+
const degradations = parseDegradations(data?.degraded);
|
|
98
|
+
|
|
99
|
+
const page: SearchPageInfo | null =
|
|
100
|
+
data === null
|
|
101
|
+
? null
|
|
102
|
+
: {
|
|
103
|
+
count: data.count,
|
|
104
|
+
exactTotal: data.exact_total,
|
|
105
|
+
countIsEstimate: countIsEstimate(data.exact_total, degradations),
|
|
106
|
+
hasNext: data.has_next,
|
|
107
|
+
hasPrev: data.has_prev,
|
|
108
|
+
sort: data.sort,
|
|
109
|
+
backend: data.backend,
|
|
110
|
+
tookMs: data.took_ms,
|
|
111
|
+
promotedCount: data.items.filter((item) => item.promoted).length,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const blockFor = (
|
|
115
|
+
hasMore: boolean,
|
|
116
|
+
code: string
|
|
117
|
+
): ActionAvailability => {
|
|
118
|
+
if (envelope.status === "loading") return actionBlocked("stapel.action.blocked.loading");
|
|
119
|
+
if (envelope.status === "failed") return actionBlockedByFailure(envelope.error);
|
|
120
|
+
return hasMore ? actionAvailable() : actionBlocked(code);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const goNext = useCallback(() => {
|
|
124
|
+
if (data?.next_anchor != null) goToAnchor(data.next_anchor, "next");
|
|
125
|
+
}, [data, goToAnchor]);
|
|
126
|
+
|
|
127
|
+
const goPrev = useCallback(() => {
|
|
128
|
+
// `prev_anchor` is emitted only when there IS a previous page; when the
|
|
129
|
+
// person is walking back to the first one the server sends none, and
|
|
130
|
+
// clearing the cursor is what "page 1" means under keyset paging.
|
|
131
|
+
if (data?.has_prev === true) goToAnchor(data.prev_anchor, "prev");
|
|
132
|
+
}, [data, goToAnchor]);
|
|
133
|
+
|
|
134
|
+
return props.children({
|
|
135
|
+
state: rows,
|
|
136
|
+
envelope,
|
|
137
|
+
page,
|
|
138
|
+
degradations,
|
|
139
|
+
isFetching: query.isFetching,
|
|
140
|
+
error: query.error ?? null,
|
|
141
|
+
next: blockFor(data?.has_next === true, SEARCH_I18N_KEYS.resultsBlockedAtEnd),
|
|
142
|
+
prev: blockFor(data?.has_prev === true, SEARCH_I18N_KEYS.resultsBlockedAtStart),
|
|
143
|
+
goNext,
|
|
144
|
+
goPrev,
|
|
145
|
+
refetch: () => {
|
|
146
|
+
void query.refetch();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import type {
|
|
4
|
+
SearchGeo,
|
|
5
|
+
SearchQueryState,
|
|
6
|
+
SearchRange,
|
|
7
|
+
} from "../api/types.js";
|
|
8
|
+
import {
|
|
9
|
+
activeFilterCount,
|
|
10
|
+
clearFilters,
|
|
11
|
+
parseSearchState,
|
|
12
|
+
patchSearchState,
|
|
13
|
+
setFilterValues,
|
|
14
|
+
setRangeValue,
|
|
15
|
+
toggleFilterValue,
|
|
16
|
+
writeSearchState,
|
|
17
|
+
} from "../state/urlState.js";
|
|
18
|
+
import type {
|
|
19
|
+
ParseSearchStateOptions,
|
|
20
|
+
SearchStateIssue,
|
|
21
|
+
SearchStatePatch,
|
|
22
|
+
} from "../state/urlState.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* THE ROUTER SEAM.
|
|
26
|
+
*
|
|
27
|
+
* Two members, and they are exactly the shape react-router v7's
|
|
28
|
+
* `useSearchParams()` returns — so binding one is a one-liner (`./router`
|
|
29
|
+
* ships it). The seam exists so the main entry carries no router: a Next.js
|
|
30
|
+
* host, a hash-router host, or a server-rendered page that reads
|
|
31
|
+
* `new URL(request.url).searchParams` all satisfy it, and the pair stays
|
|
32
|
+
* honest about the fact that URL state is a ROUTER concern it does not own.
|
|
33
|
+
*/
|
|
34
|
+
export interface SearchParamsAdapter {
|
|
35
|
+
readonly params: URLSearchParams;
|
|
36
|
+
/**
|
|
37
|
+
* Write the next query string.
|
|
38
|
+
*
|
|
39
|
+
* `replace` is meaningful, not decorative: a FILTER change pushes (so Back
|
|
40
|
+
* removes exactly the last filter — the spec's §4.2 acceptance), while a
|
|
41
|
+
* correction that the person did not perform replaces. Callers below pass
|
|
42
|
+
* it deliberately; an adapter that ignores it breaks the Back button.
|
|
43
|
+
*/
|
|
44
|
+
setParams(next: URLSearchParams, options?: { readonly replace?: boolean }): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Everything a control needs to read and move the search. */
|
|
48
|
+
export interface SearchStateBag {
|
|
49
|
+
/** The current state, parsed from the URL. Nothing else holds a copy. */
|
|
50
|
+
readonly state: SearchQueryState;
|
|
51
|
+
/** What the URL carried that could not be read (see `SearchStateIssue`). */
|
|
52
|
+
readonly issues: readonly SearchStateIssue[];
|
|
53
|
+
/** Facet values + ranges + geo the person has applied. */
|
|
54
|
+
readonly activeFilters: number;
|
|
55
|
+
|
|
56
|
+
setText(q: string): void;
|
|
57
|
+
setSort(sort: string | null): void;
|
|
58
|
+
setCategory(category: string | null): void;
|
|
59
|
+
setLanguage(lang: string | null): void;
|
|
60
|
+
toggleFilter(slug: string, value: string): void;
|
|
61
|
+
setFilter(slug: string, values: readonly string[]): void;
|
|
62
|
+
setRange(slug: string, range: SearchRange | null): void;
|
|
63
|
+
setGeo(geo: SearchGeo | null): void;
|
|
64
|
+
setLimit(limit: number | null): void;
|
|
65
|
+
clearAll(): void;
|
|
66
|
+
/** Move to a keyset page. The ONLY mutator that keeps a cursor. */
|
|
67
|
+
goToAnchor(anchor: string | null, direction: "next" | "prev"): void;
|
|
68
|
+
/** Escape hatch for a host control this pair does not ship. Goes through
|
|
69
|
+
* `patchSearchState`, so it drops the cursor like every other change. */
|
|
70
|
+
patch(patch: SearchStatePatch): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const StateContext = createContext<SearchStateBag | null>(null);
|
|
74
|
+
|
|
75
|
+
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
76
|
+
readonly adapter: SearchParamsAdapter;
|
|
77
|
+
readonly children: ReactNode;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Binds the URL to the search state. Every control below reads and writes
|
|
82
|
+
* through {@link useSearchState}; no component holds a second copy of a
|
|
83
|
+
* filter, which is what makes "copy the link into another tab" produce the
|
|
84
|
+
* same page by construction rather than by discipline.
|
|
85
|
+
*/
|
|
86
|
+
export function SearchStateProvider(
|
|
87
|
+
props: SearchStateProviderProps
|
|
88
|
+
): ReactElement {
|
|
89
|
+
const { adapter, children, ...parseOptions } = props;
|
|
90
|
+
const { params, setParams } = adapter;
|
|
91
|
+
|
|
92
|
+
// The parse options are spread into a stable dependency: a host that builds
|
|
93
|
+
// the object inline (the normal way to write it) must not reparse on every
|
|
94
|
+
// render and hand every consumer a new state object.
|
|
95
|
+
const {
|
|
96
|
+
defaultType,
|
|
97
|
+
defaultQ,
|
|
98
|
+
defaultSort,
|
|
99
|
+
defaultLimit,
|
|
100
|
+
defaultCategory,
|
|
101
|
+
defaultLang,
|
|
102
|
+
} = parseOptions;
|
|
103
|
+
|
|
104
|
+
const search = params.toString();
|
|
105
|
+
|
|
106
|
+
const parsed = useMemo(
|
|
107
|
+
() =>
|
|
108
|
+
parseSearchState(new URLSearchParams(search), {
|
|
109
|
+
defaultType,
|
|
110
|
+
...(defaultQ !== undefined ? { defaultQ } : {}),
|
|
111
|
+
...(defaultSort !== undefined ? { defaultSort } : {}),
|
|
112
|
+
...(defaultLimit !== undefined ? { defaultLimit } : {}),
|
|
113
|
+
...(defaultCategory !== undefined ? { defaultCategory } : {}),
|
|
114
|
+
...(defaultLang !== undefined ? { defaultLang } : {}),
|
|
115
|
+
}),
|
|
116
|
+
[search, defaultType, defaultQ, defaultSort, defaultLimit, defaultCategory, defaultLang]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const commit = useCallback(
|
|
120
|
+
(next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
|
|
121
|
+
setParams(writeSearchState(next, new URLSearchParams(search)), options);
|
|
122
|
+
},
|
|
123
|
+
[setParams, search]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const bag = useMemo<SearchStateBag>(() => {
|
|
127
|
+
const state = parsed.state;
|
|
128
|
+
const apply = (
|
|
129
|
+
next: SearchQueryState,
|
|
130
|
+
options?: { readonly replace?: boolean }
|
|
131
|
+
): void => {
|
|
132
|
+
commit(next, options);
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
state,
|
|
136
|
+
issues: parsed.issues,
|
|
137
|
+
activeFilters: activeFilterCount(state),
|
|
138
|
+
|
|
139
|
+
// Typing replaces rather than pushes: one history entry per letter
|
|
140
|
+
// would make Back useless, which is the control the spec's acceptance
|
|
141
|
+
// leans on for "Back removes the last filter".
|
|
142
|
+
setText: (q) => apply(patchSearchState(state, { q }), { replace: true }),
|
|
143
|
+
setSort: (sort) => apply(patchSearchState(state, { sort })),
|
|
144
|
+
setCategory: (category) => apply(patchSearchState(state, { category })),
|
|
145
|
+
setLanguage: (lang) => apply(patchSearchState(state, { lang })),
|
|
146
|
+
toggleFilter: (slug, value) => apply(toggleFilterValue(state, slug, value)),
|
|
147
|
+
setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
|
|
148
|
+
setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
|
|
149
|
+
setGeo: (geo) => apply(patchSearchState(state, { geo })),
|
|
150
|
+
// A page size is a preference, not a step through the results.
|
|
151
|
+
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
|
152
|
+
clearAll: () => apply(clearFilters(state)),
|
|
153
|
+
goToAnchor: (anchor, direction) =>
|
|
154
|
+
apply(patchSearchState(state, { anchor, direction })),
|
|
155
|
+
patch: (patch) => apply(patchSearchState(state, patch)),
|
|
156
|
+
};
|
|
157
|
+
}, [parsed, commit]);
|
|
158
|
+
|
|
159
|
+
return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The search state and its mutators. Throws when used outside
|
|
164
|
+
* `<SearchStateProvider>` — a control that silently rendered with an empty
|
|
165
|
+
* state would look like a search with no filters rather than a wiring bug.
|
|
166
|
+
*/
|
|
167
|
+
export function useSearchState(): SearchStateBag {
|
|
168
|
+
const bag = useContext(StateContext);
|
|
169
|
+
if (bag === null) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"useSearchState must be used inside <SearchStateProvider> — the URL is the state, and there is no fallback copy of it."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return bag;
|
|
175
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pair's error map (frontend-standard §4 checklist #7, frontend-core
|
|
3
|
+
* §2.5): the generated `code → { status, params, remediation, en }` catalog
|
|
4
|
+
* plus a tiny `explain()` lookup. Backs the manifest `errors` block and gives
|
|
5
|
+
* hosts a mechanical UX branch beside `t(code, params)`. The map itself is
|
|
6
|
+
* generated from the backend registry (`pnpm gen:errors`); this file only
|
|
7
|
+
* adds the lookup helper and re-exports the public surface.
|
|
8
|
+
*/
|
|
9
|
+
import { SEARCH_ERRORS } from "./generated/errors.gen.js";
|
|
10
|
+
import type { Remediation } from "./generated/errors.gen.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
SEARCH_ERRORS,
|
|
14
|
+
SEARCH_ERROR_CODES,
|
|
15
|
+
searchErrorBundleEn,
|
|
16
|
+
} from "./generated/errors.gen.js";
|
|
17
|
+
export type {
|
|
18
|
+
SearchErrorCode,
|
|
19
|
+
SearchErrorSpec,
|
|
20
|
+
Remediation,
|
|
21
|
+
} from "./generated/errors.gen.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The window refusal, named because a skin MUST branch on it.
|
|
25
|
+
*
|
|
26
|
+
* `error.400.search_window_exceeded` means "you have paged past
|
|
27
|
+
* `MAX_RESULT_WINDOW`", and its `params.window` carries the depth. Rendered as
|
|
28
|
+
* an empty result page it reads as "there is nothing here", which is false and
|
|
29
|
+
* is the exact substitution `matchList` exists to prevent — so it gets a
|
|
30
|
+
* constant rather than a string somebody retypes at each call site.
|
|
31
|
+
*/
|
|
32
|
+
export const SEARCH_WINDOW_EXCEEDED = "error.400.search_window_exceeded";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The engine is down. Distinct from "no results" in the same way
|
|
36
|
+
* `LoadFailed` is distinct from an empty list — the views turn EVERY backend
|
|
37
|
+
* exception into this 503, so a client never has to guess whether a blank
|
|
38
|
+
* page meant an outage.
|
|
39
|
+
*/
|
|
40
|
+
export const SEARCH_BACKEND_UNAVAILABLE = "error.503.search_backend_unavailable";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a backend error code to its remediation hint, or `undefined` for a
|
|
44
|
+
* code this module doesn't know (e.g. a cross-cutting `stapel.http.*`
|
|
45
|
+
* fallback). Zero guessing at runtime — a static lookup over the generated map.
|
|
46
|
+
*/
|
|
47
|
+
export function explainSearchError(code: string): Remediation | undefined {
|
|
48
|
+
return (SEARCH_ERRORS as Record<string, { remediation: Remediation }>)[code]
|
|
49
|
+
?.remediation;
|
|
50
|
+
}
|
package/src/i18n/es.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { searchErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
3
|
+
|
|
4
|
+
export { searchErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spanish bundle for search-react — the `@stapel/search-react/i18n/es`
|
|
8
|
+
* subpath, opt-in exactly like `ru`.
|
|
9
|
+
*
|
|
10
|
+
* PROVENANCE: the backend catalogue is `origin=seed:authored` and unreviewed;
|
|
11
|
+
* the pair-authored copy below is the same grade.
|
|
12
|
+
*/
|
|
13
|
+
export const searchI18nBundleEs: I18nDictionary = {
|
|
14
|
+
...searchErrorBundleEs,
|
|
15
|
+
|
|
16
|
+
"search.error.unknown": "Algo falló en la búsqueda.",
|
|
17
|
+
|
|
18
|
+
"search.results.title": "Resultados",
|
|
19
|
+
"search.results.loading": "Buscando…",
|
|
20
|
+
"search.results.load_failed": "No pudimos ejecutar esta búsqueda.",
|
|
21
|
+
"search.results.empty": "No hay nada que coincida con esta búsqueda.",
|
|
22
|
+
"search.results.retry": "Reintentar",
|
|
23
|
+
"search.results.count_approximate": "Unos {count} resultados",
|
|
24
|
+
"search.results.count_exact": "{count} resultados",
|
|
25
|
+
"search.results.took_ms": "{ms} ms",
|
|
26
|
+
"search.results.next": "Página siguiente",
|
|
27
|
+
"search.results.prev": "Página anterior",
|
|
28
|
+
"search.results.blocked.at_end": "Esta es la última página",
|
|
29
|
+
"search.results.blocked.at_start": "Esta es la primera página",
|
|
30
|
+
"search.results.window_exceeded":
|
|
31
|
+
"Esta página está más allá de lo que la búsqueda puede recorrer. Acota la búsqueda en lugar de seguir paginando.",
|
|
32
|
+
"search.results.promoted": "Promocionado",
|
|
33
|
+
"search.results.promoted_hint":
|
|
34
|
+
"Esta posición está pagada. Se marca porque la ley lo exige y no cambia lo que son los demás resultados.",
|
|
35
|
+
"search.results.distance_km": "a {km} km",
|
|
36
|
+
"search.results.untitled": "Sin título",
|
|
37
|
+
"search.results.open": "Abrir",
|
|
38
|
+
|
|
39
|
+
"search.sort.label": "Orden",
|
|
40
|
+
"search.sort.relevance": "Más relevantes",
|
|
41
|
+
"search.sort.newest": "Más recientes",
|
|
42
|
+
"search.sort.price_asc": "Precio: de menor a mayor",
|
|
43
|
+
"search.sort.price_desc": "Precio: de mayor a menor",
|
|
44
|
+
"search.sort.distance": "Más cercanos",
|
|
45
|
+
"search.sort.server_chose": "Ordenado por {sort}",
|
|
46
|
+
|
|
47
|
+
"search.facets.title": "Filtros",
|
|
48
|
+
"search.facets.loading": "Cargando filtros…",
|
|
49
|
+
"search.facets.load_failed": "No pudimos cargar los filtros.",
|
|
50
|
+
"search.facets.empty": "Esta búsqueda no ofrece filtros.",
|
|
51
|
+
"search.facets.clear": "Quitar",
|
|
52
|
+
"search.facets.clear_all": "Quitar todos los filtros ({count})",
|
|
53
|
+
"search.facets.approximate":
|
|
54
|
+
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
55
|
+
"search.facets.skipped":
|
|
56
|
+
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
57
|
+
"search.facets.not_counted": "sin contar",
|
|
58
|
+
"search.facets.drill_down_hint":
|
|
59
|
+
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
60
|
+
"search.facets.range_from": "Desde",
|
|
61
|
+
"search.facets.range_to": "Hasta",
|
|
62
|
+
"search.facets.range_apply": "Aplicar",
|
|
63
|
+
|
|
64
|
+
"search.geo.title": "Ubicación",
|
|
65
|
+
"search.geo.radius_km": "A menos de {km} km",
|
|
66
|
+
"search.geo.clear": "En cualquier lugar",
|
|
67
|
+
"search.geo.box": "Dentro del área mostrada",
|
|
68
|
+
"search.geo.center": "Cerca de {lat}, {lon}",
|
|
69
|
+
|
|
70
|
+
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
71
|
+
"search.url.issue.not_a_number": "{param} no es un número y se ignoró",
|
|
72
|
+
"search.url.issue.geo_incomplete":
|
|
73
|
+
"la ubicación necesita lat y lon, así que se ignoró",
|
|
74
|
+
"search.url.issue.bbox_malformed":
|
|
75
|
+
"el área necesita cuatro números (minLat,minLon,maxLat,maxLon) y se ignoró",
|
|
76
|
+
"search.url.issue.range_malformed":
|
|
77
|
+
"{param} no es un rango from..to y se ignoró",
|
|
78
|
+
|
|
79
|
+
"search.degraded.title": "Lo que esta búsqueda no pudo hacer",
|
|
80
|
+
"search.degraded.typo_tolerance":
|
|
81
|
+
"No se corrigieron erratas: el motor de búsqueda configurado no puede hacerlo.",
|
|
82
|
+
"search.degraded.phrase_synonyms":
|
|
83
|
+
"No se ampliaron sinónimos: el motor de búsqueda configurado no puede hacerlo.",
|
|
84
|
+
"search.degraded.exact_total": "El número de resultados es una estimación.",
|
|
85
|
+
"search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
|
|
86
|
+
"search.degraded.category_rollup":
|
|
87
|
+
"Pueden faltar subcategorías en estos resultados: el servicio de categorías no respondió.",
|
|
88
|
+
"search.degraded.scorer":
|
|
89
|
+
"El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
|
|
90
|
+
"search.degraded.unknown":
|
|
91
|
+
"La búsqueda informó de una limitación para la que esta página no tiene texto: {raw}",
|
|
92
|
+
|
|
93
|
+
"search.ranking.title": "Cómo se ordenan estos resultados",
|
|
94
|
+
"search.ranking.intro":
|
|
95
|
+
"Estos son los parámetros que determinan el orden de los resultados, con su peso relativo.",
|
|
96
|
+
"search.ranking.loading": "Cargando la información de ranking…",
|
|
97
|
+
"search.ranking.load_failed": "No pudimos cargar la información de ranking.",
|
|
98
|
+
"search.ranking.empty": "Esta instalación no declara parámetros de ranking.",
|
|
99
|
+
"search.ranking.parameter": "Parámetro",
|
|
100
|
+
"search.ranking.weight": "Peso",
|
|
101
|
+
"search.ranking.applies_to": "Se aplica a",
|
|
102
|
+
"search.ranking.inactive": "No aplicado: {reason}",
|
|
103
|
+
"search.ranking.notes": "Notas",
|
|
104
|
+
"search.ranking.link": "Cómo se ordenan estos resultados",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Register the es bundle. Call AFTER `registerSearchI18n`. */
|
|
108
|
+
export function registerSearchI18nEs(engine: I18nEngine): void {
|
|
109
|
+
engine.registerBundle("es", searchI18nBundleEs);
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/gen-errors.mjs — do not edit by hand.
|
|
2
|
+
// Source: stapel-auth canonical errors.json (backend codegen artifact).
|
|
3
|
+
// Regenerate: pnpm gen:errors · Drift gate: pnpm gen:errors:check
|
|
4
|
+
// Locale source: backend translations/errors.es.json (locale catalog
|
|
5
|
+
// beside the canonical errors.json — i18n-shipping.md §1).
|
|
6
|
+
import type { SearchErrorCode } from "./errors.gen.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `es` texts for every backend error code — complete over the registry by
|
|
10
|
+
* construction (the generator fails on a gap; the Record type fails compilation
|
|
11
|
+
* on drift). Import from the pair's `./i18n/es` subpath, NOT the main
|
|
12
|
+
* entry, so the locale stays out of hosts that don't ship it.
|
|
13
|
+
*/
|
|
14
|
+
export const searchErrorBundleEs: Record<SearchErrorCode, string> = {
|
|
15
|
+
"error.400.bad_request": "Solicitud incorrecta",
|
|
16
|
+
"error.400.captcha_invalid": "La verificación del captcha ha fallado. Inténtalo de nuevo.",
|
|
17
|
+
"error.400.captcha_required": "Se requiere el token del captcha.",
|
|
18
|
+
"error.400.expected_list": "Se esperaba una lista de elementos",
|
|
19
|
+
"error.400.field.blank": "{field} no puede estar vacío",
|
|
20
|
+
"error.400.field.does_not_exist": "{field} no existe",
|
|
21
|
+
"error.400.field.invalid": "{field} no es válido",
|
|
22
|
+
"error.400.field.invalid_choice": "{field} no es una opción válida",
|
|
23
|
+
"error.400.field.max_length": "{field} debe tener como máximo {max_length} caracteres",
|
|
24
|
+
"error.400.field.max_value": "{field} debe ser como máximo {max_value}",
|
|
25
|
+
"error.400.field.min_length": "{field} debe tener al menos {min_length} caracteres",
|
|
26
|
+
"error.400.field.min_value": "{field} debe ser como mínimo {min_value}",
|
|
27
|
+
"error.400.field.null": "{field} no puede ser nulo",
|
|
28
|
+
"error.400.field.required": "{field} es obligatorio",
|
|
29
|
+
"error.400.field.unique": "{field} debe ser único",
|
|
30
|
+
"error.400.invalid_ad_id": "ID de anuncio no válido",
|
|
31
|
+
"error.400.search_bad_cursor": "El cursor de paginación no es válido",
|
|
32
|
+
"error.400.search_bad_geo": "Filtro geográfico incorrecto: {reason}",
|
|
33
|
+
"error.400.search_bad_range": "El filtro de rango «{slug}» es incorrecto: {reason}",
|
|
34
|
+
"error.400.search_query_too_long": "El texto de búsqueda es demasiado largo",
|
|
35
|
+
"error.400.search_sort_needs_center": "Ordenar por distancia requiere lat y lon",
|
|
36
|
+
"error.400.search_too_many_facets": "Demasiados filtros de faceta (límite {limit})",
|
|
37
|
+
"error.400.search_too_many_ranges": "Demasiados filtros de rango (límite {limit})",
|
|
38
|
+
"error.400.search_unknown_doc_type": "Tipo de búsqueda desconocido «{doc_type}»",
|
|
39
|
+
"error.400.search_unknown_sort": "Orden desconocido «{sort}»",
|
|
40
|
+
"error.400.search_window_exceeded": "Esta página de resultados supera el límite de {window}. Acote la búsqueda en lugar de avanzar más páginas.",
|
|
41
|
+
"error.400.validation_error": "Error de validación",
|
|
42
|
+
"error.400.verification_failed": "La verificación ha fallado",
|
|
43
|
+
"error.400.verification_invalid_factor": "Este factor de verificación no está disponible",
|
|
44
|
+
"error.401.unauthorized": "Se requiere autenticación",
|
|
45
|
+
"error.402.payment_required": "Se requiere pago",
|
|
46
|
+
"error.403.forbidden": "No tienes permiso para realizar esta acción",
|
|
47
|
+
"error.403.network_blocked": "No se permiten solicitudes desde esta red.",
|
|
48
|
+
"error.403.search_forbidden": "No puede administrar el índice de búsqueda",
|
|
49
|
+
"error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
|
|
50
|
+
"error.403.verification_required": "Se requiere verificación adicional",
|
|
51
|
+
"error.404.ad_not_found": "Anuncio no encontrado",
|
|
52
|
+
"error.404.not_found": "Recurso solicitado no encontrado",
|
|
53
|
+
"error.404.verification_challenge_not_found": "Desafío de verificación no encontrado o caducado",
|
|
54
|
+
"error.405.method_not_allowed": "Método no permitido",
|
|
55
|
+
"error.406.not_acceptable": "No aceptable",
|
|
56
|
+
"error.408.request_timeout": "Tiempo de espera de la solicitud agotado",
|
|
57
|
+
"error.409.conflict": "El recurso ya existe",
|
|
58
|
+
"error.410.gone": "El recurso se ha eliminado permanentemente",
|
|
59
|
+
"error.413.payload_too_large": "El cuerpo de la solicitud es demasiado grande",
|
|
60
|
+
"error.415.unsupported_media_type": "Tipo de contenido no compatible",
|
|
61
|
+
"error.422.unprocessable_entity": "Entidad no procesable",
|
|
62
|
+
"error.423.locked": "El recurso está bloqueado",
|
|
63
|
+
"error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
|
|
64
|
+
"error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
|
|
65
|
+
"error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
|
|
66
|
+
"error.500.internal": "Algo salió mal",
|
|
67
|
+
"error.503.mandate_unavailable": "No se puede verificar el mandato del espacio de trabajo",
|
|
68
|
+
"error.503.search_backend_unavailable": "El motor de búsqueda no está disponible",
|
|
69
|
+
};
|