@86d-app/search 0.0.23 → 0.0.24

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.
Files changed (104) hide show
  1. package/dist/modules/search/src/__tests__/admin-settings.test.js +262 -0
  2. package/dist/modules/search/src/__tests__/controllers.test.js +853 -0
  3. package/dist/modules/search/src/__tests__/embedding-provider.test.js +150 -0
  4. package/dist/modules/search/src/__tests__/endpoint-security.test.js +250 -0
  5. package/dist/modules/search/src/__tests__/meilisearch-provider.test.js +318 -0
  6. package/dist/modules/search/src/__tests__/service-impl.test.js +703 -0
  7. package/dist/modules/search/src/__tests__/store-endpoints.test.js +295 -0
  8. package/dist/{admin/components/index.d.ts → modules/search/src/admin/components/index.jsx} +0 -1
  9. package/dist/modules/search/src/admin/components/search-analytics.jsx +230 -0
  10. package/dist/modules/search/src/admin/endpoints/analytics.js +9 -0
  11. package/dist/modules/search/src/admin/endpoints/bulk-index.js +26 -0
  12. package/dist/modules/search/src/admin/endpoints/click-analytics.js +9 -0
  13. package/dist/modules/search/src/admin/endpoints/get-settings.js +97 -0
  14. package/dist/modules/search/src/admin/endpoints/index-manage.js +32 -0
  15. package/dist/modules/search/src/admin/endpoints/index.js +21 -0
  16. package/dist/modules/search/src/admin/endpoints/popular.js +11 -0
  17. package/dist/modules/search/src/admin/endpoints/synonyms.js +30 -0
  18. package/dist/modules/search/src/admin/endpoints/zero-results.js +11 -0
  19. package/dist/modules/search/src/embedding-provider.js +77 -0
  20. package/dist/modules/search/src/index.js +75 -0
  21. package/dist/modules/search/src/meilisearch-provider.js +138 -0
  22. package/dist/modules/search/src/schema.js +61 -0
  23. package/dist/modules/search/src/service-impl.js +770 -0
  24. package/dist/modules/search/src/service.js +1 -0
  25. package/dist/modules/search/src/store/components/_hooks.js +10 -0
  26. package/dist/modules/search/src/store/components/index.jsx +9 -0
  27. package/dist/modules/search/src/store/components/search-bar.jsx +91 -0
  28. package/dist/modules/search/src/store/components/search-page.jsx +17 -0
  29. package/dist/modules/search/src/store/components/search-results.jsx +51 -0
  30. package/dist/modules/search/src/store/endpoints/click.js +15 -0
  31. package/dist/modules/search/src/store/endpoints/index.js +12 -0
  32. package/dist/modules/search/src/store/endpoints/recent.js +18 -0
  33. package/dist/modules/search/src/store/endpoints/search.js +57 -0
  34. package/dist/modules/search/src/store/endpoints/store-search.js +33 -0
  35. package/dist/modules/search/src/store/endpoints/suggest.js +12 -0
  36. package/package.json +1 -1
  37. package/src/__tests__/admin-settings.test.ts +367 -0
  38. package/src/__tests__/store-endpoints.test.ts +392 -0
  39. package/src/admin/endpoints/get-settings.ts +77 -0
  40. package/dist/__tests__/controllers.test.d.ts +0 -2
  41. package/dist/__tests__/controllers.test.d.ts.map +0 -1
  42. package/dist/__tests__/embedding-provider.test.d.ts +0 -2
  43. package/dist/__tests__/embedding-provider.test.d.ts.map +0 -1
  44. package/dist/__tests__/endpoint-security.test.d.ts +0 -2
  45. package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
  46. package/dist/__tests__/meilisearch-provider.test.d.ts +0 -2
  47. package/dist/__tests__/meilisearch-provider.test.d.ts.map +0 -1
  48. package/dist/__tests__/service-impl.test.d.ts +0 -2
  49. package/dist/__tests__/service-impl.test.d.ts.map +0 -1
  50. package/dist/admin/components/index.d.ts.map +0 -1
  51. package/dist/admin/components/search-analytics.d.ts +0 -2
  52. package/dist/admin/components/search-analytics.d.ts.map +0 -1
  53. package/dist/admin/endpoints/analytics.d.ts +0 -15
  54. package/dist/admin/endpoints/analytics.d.ts.map +0 -1
  55. package/dist/admin/endpoints/bulk-index.d.ts +0 -20
  56. package/dist/admin/endpoints/bulk-index.d.ts.map +0 -1
  57. package/dist/admin/endpoints/click-analytics.d.ts +0 -7
  58. package/dist/admin/endpoints/click-analytics.d.ts.map +0 -1
  59. package/dist/admin/endpoints/get-settings.d.ts +0 -17
  60. package/dist/admin/endpoints/get-settings.d.ts.map +0 -1
  61. package/dist/admin/endpoints/index-manage.d.ts +0 -26
  62. package/dist/admin/endpoints/index-manage.d.ts.map +0 -1
  63. package/dist/admin/endpoints/index.d.ts +0 -125
  64. package/dist/admin/endpoints/index.d.ts.map +0 -1
  65. package/dist/admin/endpoints/popular.d.ts +0 -10
  66. package/dist/admin/endpoints/popular.d.ts.map +0 -1
  67. package/dist/admin/endpoints/synonyms.d.ts +0 -30
  68. package/dist/admin/endpoints/synonyms.d.ts.map +0 -1
  69. package/dist/admin/endpoints/zero-results.d.ts +0 -10
  70. package/dist/admin/endpoints/zero-results.d.ts.map +0 -1
  71. package/dist/embedding-provider.d.ts +0 -28
  72. package/dist/embedding-provider.d.ts.map +0 -1
  73. package/dist/index.d.ts +0 -23
  74. package/dist/index.d.ts.map +0 -1
  75. package/dist/meilisearch-provider.d.ts +0 -104
  76. package/dist/meilisearch-provider.d.ts.map +0 -1
  77. package/dist/schema.d.ts +0 -133
  78. package/dist/schema.d.ts.map +0 -1
  79. package/dist/service-impl.d.ts +0 -6
  80. package/dist/service-impl.d.ts.map +0 -1
  81. package/dist/service.d.ts +0 -127
  82. package/dist/service.d.ts.map +0 -1
  83. package/dist/store/components/_hooks.d.ts +0 -6
  84. package/dist/store/components/_hooks.d.ts.map +0 -1
  85. package/dist/store/components/index.d.ts +0 -10
  86. package/dist/store/components/index.d.ts.map +0 -1
  87. package/dist/store/components/search-bar.d.ts +0 -7
  88. package/dist/store/components/search-bar.d.ts.map +0 -1
  89. package/dist/store/components/search-page.d.ts +0 -4
  90. package/dist/store/components/search-page.d.ts.map +0 -1
  91. package/dist/store/components/search-results.d.ts +0 -9
  92. package/dist/store/components/search-results.d.ts.map +0 -1
  93. package/dist/store/endpoints/click.d.ts +0 -14
  94. package/dist/store/endpoints/click.d.ts.map +0 -1
  95. package/dist/store/endpoints/index.d.ts +0 -85
  96. package/dist/store/endpoints/index.d.ts.map +0 -1
  97. package/dist/store/endpoints/recent.d.ts +0 -15
  98. package/dist/store/endpoints/recent.d.ts.map +0 -1
  99. package/dist/store/endpoints/search.d.ts +0 -36
  100. package/dist/store/endpoints/search.d.ts.map +0 -1
  101. package/dist/store/endpoints/store-search.d.ts +0 -16
  102. package/dist/store/endpoints/store-search.d.ts.map +0 -1
  103. package/dist/store/endpoints/suggest.d.ts +0 -11
  104. package/dist/store/endpoints/suggest.d.ts.map +0 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { useModuleClient } from "@86d-app/core/client";
3
+ export function useSearchApi() {
4
+ const client = useModuleClient();
5
+ return {
6
+ search: client.module("search").store["/search"],
7
+ suggest: client.module("search").store["/search/suggest"],
8
+ recent: client.module("search").store["/search/recent"],
9
+ };
10
+ }
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { SearchBar } from "./search-bar";
3
+ import { SearchPage } from "./search-page";
4
+ import { SearchResults } from "./search-results";
5
+ export default {
6
+ SearchBar,
7
+ SearchResults,
8
+ SearchPage,
9
+ };
@@ -0,0 +1,91 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useSearchApi } from "./_hooks";
4
+ export function SearchBar({ placeholder = "Search...", onSearch, }) {
5
+ const api = useSearchApi();
6
+ const [query, setQuery] = useState("");
7
+ const [suggestions, setSuggestions] = useState([]);
8
+ const [showSuggestions, setShowSuggestions] = useState(false);
9
+ const [selectedIndex, setSelectedIndex] = useState(-1);
10
+ const inputRef = useRef(null);
11
+ const containerRef = useRef(null);
12
+ const { data: suggestData } = query.trim().length >= 2
13
+ ? api.suggest.useQuery({ q: query.trim(), limit: "8" })
14
+ : { data: undefined };
15
+ useEffect(() => {
16
+ if (suggestData?.suggestions) {
17
+ setSuggestions(suggestData.suggestions);
18
+ }
19
+ else {
20
+ setSuggestions([]);
21
+ }
22
+ }, [suggestData]);
23
+ const handleSubmit = useCallback((term) => {
24
+ const trimmed = term.trim();
25
+ if (trimmed.length === 0)
26
+ return;
27
+ setShowSuggestions(false);
28
+ setSelectedIndex(-1);
29
+ onSearch?.(trimmed);
30
+ }, [onSearch]);
31
+ const handleKeyDown = (e) => {
32
+ if (e.key === "ArrowDown") {
33
+ e.preventDefault();
34
+ setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
35
+ }
36
+ else if (e.key === "ArrowUp") {
37
+ e.preventDefault();
38
+ setSelectedIndex((i) => Math.max(i - 1, -1));
39
+ }
40
+ else if (e.key === "Enter") {
41
+ e.preventDefault();
42
+ if (selectedIndex >= 0 && suggestions[selectedIndex]) {
43
+ setQuery(suggestions[selectedIndex]);
44
+ handleSubmit(suggestions[selectedIndex]);
45
+ }
46
+ else {
47
+ handleSubmit(query);
48
+ }
49
+ }
50
+ else if (e.key === "Escape") {
51
+ setShowSuggestions(false);
52
+ setSelectedIndex(-1);
53
+ }
54
+ };
55
+ // Close suggestions on click outside
56
+ useEffect(() => {
57
+ const handleClick = (e) => {
58
+ if (containerRef.current &&
59
+ !containerRef.current.contains(e.target)) {
60
+ setShowSuggestions(false);
61
+ }
62
+ };
63
+ document.addEventListener("mousedown", handleClick);
64
+ return () => document.removeEventListener("mousedown", handleClick);
65
+ }, []);
66
+ return (<div ref={containerRef} className="relative w-full">
67
+ <div className="relative">
68
+ <svg className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" aria-hidden="true">
69
+ <title>Search</title>
70
+ <path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
71
+ </svg>
72
+ <input ref={inputRef} type="search" value={query} onChange={(e) => {
73
+ setQuery(e.target.value);
74
+ setShowSuggestions(true);
75
+ setSelectedIndex(-1);
76
+ }} onFocus={() => setShowSuggestions(true)} onKeyDown={handleKeyDown} placeholder={placeholder} className="w-full rounded-lg border border-border bg-background py-2.5 pr-4 pl-10 text-foreground text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" aria-label="Search" aria-autocomplete="list" aria-expanded={showSuggestions && suggestions.length > 0} role="combobox"/>
77
+ </div>
78
+
79
+ {showSuggestions && suggestions.length > 0 && (<div role="listbox" className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-background shadow-lg">
80
+ {suggestions.map((suggestion, index) => (<div key={suggestion} role="option" tabIndex={-1} aria-selected={index === selectedIndex} className={`cursor-pointer px-4 py-2 text-sm ${index === selectedIndex
81
+ ? "bg-muted text-foreground"
82
+ : "text-foreground hover:bg-muted/50"}`} onMouseDown={(e) => {
83
+ e.preventDefault();
84
+ setQuery(suggestion);
85
+ handleSubmit(suggestion);
86
+ }} onMouseEnter={() => setSelectedIndex(index)}>
87
+ {suggestion}
88
+ </div>))}
89
+ </div>)}
90
+ </div>);
91
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ import { SearchBar } from "./search-bar";
4
+ import { SearchResults } from "./search-results";
5
+ export function SearchPage({ sessionId }) {
6
+ const [query, setQuery] = useState("");
7
+ const handleSearch = useCallback((term) => {
8
+ setQuery(term);
9
+ }, []);
10
+ return (<div className="mx-auto max-w-2xl px-4 py-8">
11
+ <h1 className="mb-6 font-semibold text-2xl text-foreground">Search</h1>
12
+ <SearchBar placeholder="Search products, articles..." onSearch={handleSearch}/>
13
+ <div className="mt-6">
14
+ <SearchResults query={query} sessionId={sessionId}/>
15
+ </div>
16
+ </div>);
17
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { useSearchApi } from "./_hooks";
3
+ export function SearchResults({ query, entityType, sessionId, limit = 20, }) {
4
+ const api = useSearchApi();
5
+ const { data, isLoading } = query.trim().length > 0
6
+ ? api.search.useQuery({
7
+ q: query.trim(),
8
+ type: entityType,
9
+ limit: String(limit),
10
+ sessionId,
11
+ })
12
+ : { data: undefined, isLoading: false };
13
+ const results = data?.results ?? [];
14
+ const total = data?.total ?? 0;
15
+ if (!query.trim())
16
+ return null;
17
+ if (isLoading) {
18
+ return (<div className="py-12 text-center">
19
+ <div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-muted border-t-foreground"/>
20
+ <p className="mt-3 text-muted-foreground text-sm">Searching...</p>
21
+ </div>);
22
+ }
23
+ if (results.length === 0) {
24
+ return (<div className="py-12 text-center">
25
+ <p className="text-foreground">
26
+ No results found for &ldquo;{query}&rdquo;
27
+ </p>
28
+ <p className="mt-1 text-muted-foreground text-sm">
29
+ Try different keywords or check the spelling.
30
+ </p>
31
+ </div>);
32
+ }
33
+ return (<div>
34
+ <p className="mb-4 text-muted-foreground text-sm">
35
+ {total} result{total !== 1 ? "s" : ""} for &ldquo;{query}&rdquo;
36
+ </p>
37
+ <div className="space-y-3">
38
+ {results.map((result) => (<a key={result.id} href={result.url} className="block rounded-lg border border-border p-4 transition-colors hover:bg-muted/50">
39
+ <div className="flex items-start gap-4">
40
+ {result.image && (<img src={result.image} alt="" className="h-16 w-16 rounded-md object-cover"/>)}
41
+ <div className="min-w-0 flex-1">
42
+ <h3 className="font-medium text-foreground">{result.title}</h3>
43
+ <p className="mt-0.5 text-muted-foreground text-xs capitalize">
44
+ {result.entityType}
45
+ </p>
46
+ </div>
47
+ </div>
48
+ </a>))}
49
+ </div>
50
+ </div>);
51
+ }
@@ -0,0 +1,15 @@
1
+ import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ export const clickEndpoint = createStoreEndpoint("/search/click", {
3
+ method: "POST",
4
+ body: z.object({
5
+ queryId: z.string().min(1).max(200),
6
+ term: z.string().min(1).max(500).transform(sanitizeText),
7
+ entityType: z.string().min(1).max(100).transform(sanitizeText),
8
+ entityId: z.string().min(1).max(200),
9
+ position: z.number().int().min(0).max(1000),
10
+ }),
11
+ }, async (ctx) => {
12
+ const controller = ctx.context.controllers.search;
13
+ const click = await controller.recordClick(ctx.body);
14
+ return { id: click.id };
15
+ });
@@ -0,0 +1,12 @@
1
+ import { clickEndpoint } from "./click";
2
+ import { recentEndpoint } from "./recent";
3
+ import { searchEndpoint } from "./search";
4
+ import { storeSearch } from "./store-search";
5
+ import { suggestEndpoint } from "./suggest";
6
+ export const storeEndpoints = {
7
+ "/search/store-search": storeSearch,
8
+ "/search": searchEndpoint,
9
+ "/search/suggest": suggestEndpoint,
10
+ "/search/recent": recentEndpoint,
11
+ "/search/click": clickEndpoint,
12
+ };
@@ -0,0 +1,18 @@
1
+ import { createStoreEndpoint, z } from "@86d-app/core";
2
+ export const recentEndpoint = createStoreEndpoint("/search/recent", {
3
+ method: "GET",
4
+ query: z.object({
5
+ sessionId: z.string().min(1).max(128),
6
+ limit: z.coerce.number().int().min(1).max(20).optional(),
7
+ }),
8
+ }, async (ctx) => {
9
+ const controller = ctx.context.controllers.search;
10
+ const queries = await controller.getRecentQueries(ctx.query.sessionId, ctx.query.limit ?? 10);
11
+ return {
12
+ recent: queries.map((q) => ({
13
+ term: q.term,
14
+ resultCount: q.resultCount,
15
+ searchedAt: q.searchedAt,
16
+ })),
17
+ };
18
+ });
@@ -0,0 +1,57 @@
1
+ import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ const sortFields = [
3
+ "relevance",
4
+ "newest",
5
+ "oldest",
6
+ "title_asc",
7
+ "title_desc",
8
+ ];
9
+ export const searchEndpoint = createStoreEndpoint("/search", {
10
+ method: "GET",
11
+ query: z.object({
12
+ q: z.string().min(1).max(500).transform(sanitizeText),
13
+ type: z.string().max(100).transform(sanitizeText).optional(),
14
+ tags: z.string().max(2000).transform(sanitizeText).optional(),
15
+ sort: z.enum(sortFields).optional(),
16
+ fuzzy: z.coerce.boolean().optional(),
17
+ limit: z.coerce.number().int().min(1).max(100).optional(),
18
+ skip: z.coerce.number().int().min(0).optional(),
19
+ sessionId: z.string().max(200).optional(),
20
+ }),
21
+ }, async (ctx) => {
22
+ const controller = ctx.context.controllers.search;
23
+ const parsedTags = ctx.query.tags
24
+ ? ctx.query.tags
25
+ .split(",")
26
+ .map((t) => t.trim())
27
+ .filter(Boolean)
28
+ : undefined;
29
+ const { results, total, facets, didYouMean } = await controller.search(ctx.query.q, {
30
+ entityType: ctx.query.type,
31
+ tags: parsedTags,
32
+ sort: ctx.query.sort,
33
+ fuzzy: ctx.query.fuzzy,
34
+ limit: ctx.query.limit ?? 20,
35
+ skip: ctx.query.skip ?? 0,
36
+ });
37
+ // Record query for analytics (fire-and-forget)
38
+ controller
39
+ .recordQuery(ctx.query.q, total, ctx.query.sessionId)
40
+ .catch(() => { });
41
+ return {
42
+ results: results.map((r) => ({
43
+ id: r.item.id,
44
+ entityType: r.item.entityType,
45
+ entityId: r.item.entityId,
46
+ title: r.item.title,
47
+ url: r.item.url,
48
+ image: r.item.image,
49
+ tags: r.item.tags,
50
+ score: r.score,
51
+ highlights: r.highlights,
52
+ })),
53
+ total,
54
+ facets,
55
+ didYouMean,
56
+ };
57
+ });
@@ -0,0 +1,33 @@
1
+ import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ export const storeSearch = createStoreEndpoint("/search/store-search", {
3
+ method: "GET",
4
+ query: z.object({
5
+ q: z.string().min(0).max(500).transform(sanitizeText),
6
+ limit: z.string().max(10).optional(),
7
+ }),
8
+ }, async (ctx) => {
9
+ const controller = ctx.context.controllers.search;
10
+ const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 5;
11
+ const q = ctx.query.q.trim();
12
+ if (q.length === 0) {
13
+ return {
14
+ results: [
15
+ {
16
+ id: "search",
17
+ label: "Search",
18
+ href: "/search",
19
+ group: "Pages",
20
+ },
21
+ ],
22
+ };
23
+ }
24
+ const { results } = await controller.search(q, { limit });
25
+ return {
26
+ results: results.map((r) => ({
27
+ id: r.item.id,
28
+ label: r.item.title,
29
+ href: r.item.url,
30
+ group: r.item.entityType,
31
+ })),
32
+ };
33
+ });
@@ -0,0 +1,12 @@
1
+ import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ export const suggestEndpoint = createStoreEndpoint("/search/suggest", {
3
+ method: "GET",
4
+ query: z.object({
5
+ q: z.string().min(1).max(200).transform(sanitizeText),
6
+ limit: z.coerce.number().int().min(1).max(20).optional(),
7
+ }),
8
+ }, async (ctx) => {
9
+ const controller = ctx.context.controllers.search;
10
+ const suggestions = await controller.suggest(ctx.query.q, ctx.query.limit ?? 8);
11
+ return { suggestions };
12
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@86d-app/search",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "Unified search, autocomplete, and search analytics module for 86d commerce platform",
5
5
  "keywords": [
6
6
  "commerce",