@findlane/browser 0.2.0 → 0.3.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/dist/index.d.ts CHANGED
@@ -39,6 +39,20 @@ export type SearchResponse<T> = {
39
39
  facets: Record<string, Record<string, number>>;
40
40
  tookMs: number;
41
41
  sort?: string;
42
+ /** With the index's `didYouMean` setting: the query with the words typo tolerance corrected, when the
43
+ * results matched some words only by typo ("Showing results for …"), else null. */
44
+ didYouMean?: DidYouMean | null;
45
+ };
46
+ /** A corrected query. `applied`: the results are already for `q` (a search's "Showing results for");
47
+ * otherwise searching `q` finds results that the query didn't ("Did you mean"). `changes` lists each
48
+ * changed part of the query as typed. */
49
+ export type DidYouMean = {
50
+ q: string;
51
+ applied: boolean;
52
+ changes: {
53
+ from: string;
54
+ to: string;
55
+ }[];
42
56
  };
43
57
  export declare class FindlaneError extends Error {
44
58
  status: number;
@@ -56,7 +70,14 @@ export declare class SearchBrowserClient {
56
70
  private readonly url;
57
71
  private readonly fetcher;
58
72
  constructor(options: BrowserClientOptions);
73
+ private post;
59
74
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
60
75
  signal?: AbortSignal;
61
76
  }): Promise<SearchResponse<T>>;
77
+ /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
78
+ * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
79
+ * Ask once the shopper stops typing, not for every keystroke. */
80
+ suggest(request: SearchRequest, options?: {
81
+ signal?: AbortSignal;
82
+ }): Promise<DidYouMean | null>;
62
83
  }
package/dist/index.js CHANGED
@@ -14,19 +14,28 @@ export class SearchBrowserClient {
14
14
  constructor(options) {
15
15
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
16
16
  throw new Error("A valid public index ID (pub_…) is required");
17
- this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}/search`;
17
+ this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
18
18
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
19
19
  }
20
- async search(request, options = {}) {
21
- const response = await this.fetcher(this.url, {
20
+ async post(operation, request, signal) {
21
+ const response = await this.fetcher(`${this.url}/${operation}`, {
22
22
  method: "POST",
23
23
  headers: { "content-type": "application/json" },
24
24
  body: JSON.stringify(request),
25
- signal: options.signal,
25
+ signal,
26
26
  });
27
27
  const data = await response.json();
28
28
  if (!response.ok)
29
29
  throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
30
30
  return data;
31
31
  }
32
+ search(request, options = {}) {
33
+ return this.post("search", request, options.signal);
34
+ }
35
+ /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
36
+ * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
37
+ * Ask once the shopper stops typing, not for every keystroke. */
38
+ async suggest(request, options = {}) {
39
+ return (await this.post("suggest", request, options.signal)).didYouMean;
40
+ }
32
41
  }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { DidYouMean as Correction } from "../index.js";
2
3
  import { type Refinement } from "./search-provider.js";
3
4
  import type { Hit } from "./state.js";
4
5
  import { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
@@ -80,6 +81,15 @@ export declare function LoadMore({ label, className }: {
80
81
  label?: string;
81
82
  className?: string;
82
83
  }): import("react").JSX.Element | null;
84
+ /** "Showing results for …" when typo tolerance corrected the query, and "Did you mean …?", a button that
85
+ * searches for it, when nothing matched. Needs the index's didYouMean setting; otherwise shows nothing. */
86
+ export declare function DidYouMean({ delay, format, className }: {
87
+ /** How long the visitor must stop typing before a suggestion is asked for (default 400 ms). */
88
+ delay?: number;
89
+ /** The line's content (default "Showing results for …" or "Did you mean …?"). */
90
+ format?: (didYouMean: Correction, apply: () => void) => ReactNode;
91
+ className?: string;
92
+ }): import("react").JSX.Element | null;
83
93
  /** "1,234 results". */
84
94
  export declare function Stats({ format, className }: {
85
95
  format?: (total: number) => ReactNode;
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { useState } from "react";
4
4
  import { Highlight } from "./highlight.js";
5
5
  import { messageFor } from "./messages.js";
6
- import { useCurrentRefinements, useHits, useLoadMore, useRefinementList, useSearchBox, useSortBy, } from "./search-provider.js";
6
+ import { useCurrentRefinements, useDidYouMean, useHits, useLoadMore, useRefinementList, useSearchBox, useSortBy, } from "./search-provider.js";
7
7
  import { useAutocomplete } from "./use-autocomplete.js";
8
8
  // The components render plain elements marked with data-findlane-* attributes and no styles of
9
9
  // their own; styles.css gives them a default look.
@@ -74,6 +74,16 @@ export function LoadMore({ label = "Show more", className }) {
74
74
  return null;
75
75
  return (_jsxs("div", { className: className, "data-findlane-load-more": "", children: [_jsx("button", { type: "button", onClick: loadMore, disabled: isLoading, children: isLoading ? "Loading…" : label }), _jsxs("span", { children: [number(shown), " of ", number(total)] })] }));
76
76
  }
77
+ /** "Showing results for …" when typo tolerance corrected the query, and "Did you mean …?", a button that
78
+ * searches for it, when nothing matched. Needs the index's didYouMean setting; otherwise shows nothing. */
79
+ export function DidYouMean({ delay, format, className }) {
80
+ const { didYouMean, apply } = useDidYouMean({ delay });
81
+ if (!didYouMean)
82
+ return null;
83
+ return (_jsx("p", { className: className, "data-findlane-did-you-mean": "", children: format ? format(didYouMean, apply) : didYouMean.applied
84
+ ? _jsxs(_Fragment, { children: ["Showing results for ", _jsx("b", { children: didYouMean.q })] })
85
+ : _jsxs(_Fragment, { children: ["Did you mean ", _jsx("button", { type: "button", onClick: apply, children: didYouMean.q }), "?"] }) }));
86
+ }
77
87
  /** "1,234 results". */
78
88
  export function Stats({ format, className }) {
79
89
  const { total, totalRelation, isLoaded } = useHits();
@@ -1,8 +1,8 @@
1
1
  export { FindlaneProvider, useClient, type ClientOptions } from "./client.js";
2
2
  export { useSearch, type UseSearchOptions, type UseSearchResult } from "./use-search.js";
3
3
  export { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
4
- export { SearchProvider, useCurrentRefinements, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
5
- export { CurrentRefinements, DefaultHit, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
4
+ export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
5
+ export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
6
6
  export { SearchDialog, type SearchDialogProps } from "./dialog.js";
7
7
  export { Highlight, Snippet, excerpt, highlightParts, type HighlightProps } from "./highlight.js";
8
8
  export { messageFor } from "./messages.js";
@@ -3,8 +3,8 @@
3
3
  export { FindlaneProvider, useClient } from "./client.js";
4
4
  export { useSearch } from "./use-search.js";
5
5
  export { useAutocomplete } from "./use-autocomplete.js";
6
- export { SearchProvider, useCurrentRefinements, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
7
- export { CurrentRefinements, DefaultHit, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
6
+ export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
7
+ export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
8
8
  export { SearchDialog } from "./dialog.js";
9
9
  export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
10
10
  export { messageFor } from "./messages.js";
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { DidYouMean } from "../index.js";
2
3
  import { type ClientOptions } from "./client.js";
3
4
  import { type ServerResults, type StateChange } from "./store.js";
4
5
  import { type SearchConfig, type SearchState } from "./state.js";
@@ -45,6 +46,16 @@ export declare function useHits<T = Record<string, unknown>>(): {
45
46
  isPending: boolean;
46
47
  error: unknown;
47
48
  };
49
+ /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
50
+ * corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
51
+ * finds results, asked for once the visitor has stopped typing for `delay` ms. `apply` searches for it.
52
+ * Searches carry `didYouMean` only when the setting is on, so other indexes are never asked. */
53
+ export declare function useDidYouMean({ delay }?: {
54
+ delay?: number;
55
+ }): {
56
+ didYouMean: DidYouMean | null;
57
+ apply: () => void;
58
+ };
48
59
  /** A facet's values with their counts, most common first. Selected values stay listed (at 0 when the
49
60
  * query no longer matches them), so they can be removed. */
50
61
  export declare function useRefinementList(field: string): {
@@ -94,6 +94,32 @@ export function useHits() {
94
94
  error,
95
95
  };
96
96
  }
97
+ /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
98
+ * corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
99
+ * finds results, asked for once the visitor has stopped typing for `delay` ms. `apply` searches for it.
100
+ * Searches carry `didYouMean` only when the setting is on, so other indexes are never asked. */
101
+ export function useDidYouMean({ delay = 400 } = {}) {
102
+ const [{ results, pending }, store] = useSnapshot();
103
+ const [suggested, setSuggested] = useState(null);
104
+ const empty = Boolean(results && !pending && !results.response.total && results.request.q?.trim() &&
105
+ results.response.didYouMean !== undefined);
106
+ const key = results?.key;
107
+ useEffect(() => {
108
+ if (!empty || !results)
109
+ return;
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => store.suggest(results.request, controller.signal)
112
+ .then((didYouMean) => setSuggested({ key: results.key, didYouMean }), () => undefined), delay);
113
+ return () => { clearTimeout(timer); controller.abort(); };
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [store, key, empty, delay]);
116
+ const didYouMean = results?.response.didYouMean ?? (empty && suggested?.key === key ? suggested.didYouMean : null);
117
+ return {
118
+ didYouMean,
119
+ apply: () => { if (didYouMean)
120
+ store.update((current) => ({ ...current, q: didYouMean.q, page: 0 })); },
121
+ };
122
+ }
97
123
  /** A facet's values with their counts, most common first. Selected values stay listed (at 0 when the
98
124
  * query no longer matches them), so they can be removed. */
99
125
  export function useRefinementList(field) {
@@ -1,4 +1,4 @@
1
- import type { SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
1
+ import type { DidYouMean, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
2
2
  import { type Hit, type SearchConfig, type SearchState } from "./state.js";
3
3
  /** Results computed on the server (see ssr.ts), so the first render needn't search again. */
4
4
  export type ServerResults<T = unknown> = {
@@ -60,6 +60,8 @@ export declare class SearchStore<T = unknown> {
60
60
  stop(): void;
61
61
  private resultsFor;
62
62
  private search;
63
+ /** A did-you-mean suggestion for a request that found nothing. */
64
+ suggest(request: SearchRequest, signal: AbortSignal): Promise<DidYouMean | null>;
63
65
  /** Adds the next page's hits to the current ones. */
64
66
  loadMore(): void;
65
67
  }
@@ -107,6 +107,10 @@ export class SearchStore {
107
107
  else
108
108
  go();
109
109
  }
110
+ /** A did-you-mean suggestion for a request that found nothing. */
111
+ suggest(request, signal) {
112
+ return this.client.suggest(request, { signal });
113
+ }
110
114
  /** Adds the next page's hits to the current ones. */
111
115
  loadMore() {
112
116
  const { results, loadingMore, pending } = this.snapshot;
@@ -239,6 +239,21 @@
239
239
  cursor: pointer;
240
240
  }
241
241
 
242
+ :where([data-findlane-did-you-mean]) {
243
+ margin: 0;
244
+ color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
245
+ }
246
+ :where([data-findlane-did-you-mean]) :where(button) {
247
+ padding: 0;
248
+ font: inherit;
249
+ font-weight: 600;
250
+ color: var(--findlane-accent, #2f5bff);
251
+ background: none;
252
+ border: 0;
253
+ text-decoration: underline;
254
+ cursor: pointer;
255
+ }
256
+
242
257
  :where([data-findlane-stats]) {
243
258
  margin: 0;
244
259
  color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
6
6
  "license": "MIT",