@86d-app/search 0.0.3

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.
@@ -0,0 +1,292 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useState } from "react";
5
+
6
+ interface AnalyticsData {
7
+ totalQueries: number;
8
+ uniqueTerms: number;
9
+ avgResultCount: number;
10
+ zeroResultCount: number;
11
+ zeroResultRate: number;
12
+ indexedItems: number;
13
+ }
14
+
15
+ interface PopularTerm {
16
+ term: string;
17
+ count: number;
18
+ avgResultCount: number;
19
+ }
20
+
21
+ interface Synonym {
22
+ id: string;
23
+ term: string;
24
+ synonyms: string[];
25
+ createdAt: string;
26
+ }
27
+
28
+ function useSearchAdminApi() {
29
+ const client = useModuleClient();
30
+ return {
31
+ analytics: client.module("search").admin["/admin/search/analytics"],
32
+ popular: client.module("search").admin["/admin/search/popular"],
33
+ zeroResults: client.module("search").admin["/admin/search/zero-results"],
34
+ synonyms: client.module("search").admin["/admin/search/synonyms"],
35
+ addSynonym: client.module("search").admin["/admin/search/synonyms/add"],
36
+ removeSynonym:
37
+ client.module("search").admin["/admin/search/synonyms/:id/delete"],
38
+ };
39
+ }
40
+
41
+ function StatCard({ label, value }: { label: string; value: string | number }) {
42
+ return (
43
+ <div className="rounded-lg border border-border bg-background p-4">
44
+ <p className="text-muted-foreground text-xs uppercase tracking-wider">
45
+ {label}
46
+ </p>
47
+ <p className="mt-1 font-semibold text-2xl text-foreground">{value}</p>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export function SearchAnalytics() {
53
+ const api = useSearchAdminApi();
54
+ const [newTerm, setNewTerm] = useState("");
55
+ const [newSynonyms, setNewSynonyms] = useState("");
56
+ const [error, setError] = useState("");
57
+
58
+ const { data: analyticsData, isLoading: analyticsLoading } =
59
+ api.analytics.useQuery({}) as {
60
+ data: { analytics: AnalyticsData } | undefined;
61
+ isLoading: boolean;
62
+ };
63
+
64
+ const { data: popularData, isLoading: popularLoading } = api.popular.useQuery(
65
+ { limit: "15" },
66
+ ) as {
67
+ data: { terms: PopularTerm[] } | undefined;
68
+ isLoading: boolean;
69
+ };
70
+
71
+ const { data: zeroData, isLoading: zeroLoading } = api.zeroResults.useQuery({
72
+ limit: "15",
73
+ }) as {
74
+ data: { terms: PopularTerm[] } | undefined;
75
+ isLoading: boolean;
76
+ };
77
+
78
+ const { data: synonymsData, isLoading: synonymsLoading } =
79
+ api.synonyms.useQuery({}) as {
80
+ data: { synonyms: Synonym[] } | undefined;
81
+ isLoading: boolean;
82
+ };
83
+
84
+ const addMutation = api.addSynonym.useMutation({
85
+ onSettled: () => {
86
+ void api.synonyms.invalidate();
87
+ setNewTerm("");
88
+ setNewSynonyms("");
89
+ },
90
+ onError: () => {
91
+ setError("Failed to add synonym.");
92
+ },
93
+ });
94
+
95
+ const removeMutation = api.removeSynonym.useMutation({
96
+ onSettled: () => {
97
+ void api.synonyms.invalidate();
98
+ },
99
+ });
100
+
101
+ const analytics = analyticsData?.analytics;
102
+ const popularTerms = popularData?.terms ?? [];
103
+ const zeroResultTerms = zeroData?.terms ?? [];
104
+ const synonyms = synonymsData?.synonyms ?? [];
105
+ const loading =
106
+ analyticsLoading || popularLoading || zeroLoading || synonymsLoading;
107
+
108
+ const handleAddSynonym = () => {
109
+ setError("");
110
+ const term = newTerm.trim();
111
+ const syns = newSynonyms
112
+ .split(",")
113
+ .map((s) => s.trim())
114
+ .filter((s) => s.length > 0);
115
+ if (!term || syns.length === 0) {
116
+ setError("Enter a term and at least one synonym.");
117
+ return;
118
+ }
119
+ addMutation.mutate({ term, synonyms: syns });
120
+ };
121
+
122
+ if (loading && !analytics) {
123
+ return (
124
+ <div className="py-16 text-center">
125
+ <div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-foreground" />
126
+ <p className="mt-4 text-muted-foreground text-sm">
127
+ Loading search analytics...
128
+ </p>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <div className="space-y-8">
135
+ {/* Stats overview */}
136
+ {analytics && (
137
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
138
+ <StatCard
139
+ label="Total Searches"
140
+ value={analytics.totalQueries.toLocaleString()}
141
+ />
142
+ <StatCard
143
+ label="Unique Terms"
144
+ value={analytics.uniqueTerms.toLocaleString()}
145
+ />
146
+ <StatCard label="Avg Results" value={analytics.avgResultCount} />
147
+ <StatCard
148
+ label="Zero Results"
149
+ value={analytics.zeroResultCount.toLocaleString()}
150
+ />
151
+ <StatCard
152
+ label="Zero Result Rate"
153
+ value={`${analytics.zeroResultRate}%`}
154
+ />
155
+ <StatCard
156
+ label="Indexed Items"
157
+ value={analytics.indexedItems.toLocaleString()}
158
+ />
159
+ </div>
160
+ )}
161
+
162
+ <div className="grid gap-8 md:grid-cols-2">
163
+ {/* Popular terms */}
164
+ <div className="rounded-lg border border-border bg-background">
165
+ <div className="border-border border-b px-5 py-3">
166
+ <h3 className="font-medium text-foreground text-sm">
167
+ Popular Search Terms
168
+ </h3>
169
+ </div>
170
+ {popularTerms.length === 0 ? (
171
+ <p className="px-5 py-6 text-center text-muted-foreground text-sm">
172
+ No search data yet.
173
+ </p>
174
+ ) : (
175
+ <div className="divide-y divide-border">
176
+ {popularTerms.map((t) => (
177
+ <div
178
+ key={t.term}
179
+ className="flex items-center justify-between px-5 py-2.5"
180
+ >
181
+ <span className="text-foreground text-sm">{t.term}</span>
182
+ <span className="text-muted-foreground text-xs">
183
+ {t.count} searches &middot; {t.avgResultCount} avg results
184
+ </span>
185
+ </div>
186
+ ))}
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ {/* Zero result queries */}
192
+ <div className="rounded-lg border border-border bg-background">
193
+ <div className="border-border border-b px-5 py-3">
194
+ <h3 className="font-medium text-foreground text-sm">
195
+ Zero-Result Queries
196
+ </h3>
197
+ </div>
198
+ {zeroResultTerms.length === 0 ? (
199
+ <p className="px-5 py-6 text-center text-muted-foreground text-sm">
200
+ No zero-result queries yet.
201
+ </p>
202
+ ) : (
203
+ <div className="divide-y divide-border">
204
+ {zeroResultTerms.map((t) => (
205
+ <div
206
+ key={t.term}
207
+ className="flex items-center justify-between px-5 py-2.5"
208
+ >
209
+ <span className="text-foreground text-sm">{t.term}</span>
210
+ <span className="text-muted-foreground text-xs">
211
+ {t.count} times
212
+ </span>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ )}
217
+ </div>
218
+ </div>
219
+
220
+ {/* Synonyms management */}
221
+ <div className="rounded-lg border border-border bg-background">
222
+ <div className="border-border border-b px-5 py-3">
223
+ <h3 className="font-medium text-foreground text-sm">
224
+ Search Synonyms
225
+ </h3>
226
+ </div>
227
+ <div className="p-5">
228
+ <div className="mb-4 flex flex-col gap-2 sm:flex-row">
229
+ <input
230
+ type="text"
231
+ value={newTerm}
232
+ onChange={(e) => setNewTerm(e.target.value)}
233
+ placeholder="Term (e.g. tee)"
234
+ className="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
235
+ />
236
+ <input
237
+ type="text"
238
+ value={newSynonyms}
239
+ onChange={(e) => setNewSynonyms(e.target.value)}
240
+ placeholder="Synonyms, comma separated (e.g. t-shirt, shirt)"
241
+ className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
242
+ />
243
+ <button
244
+ type="button"
245
+ onClick={handleAddSynonym}
246
+ disabled={addMutation.isPending}
247
+ className="rounded-md bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
248
+ >
249
+ Add
250
+ </button>
251
+ </div>
252
+ {error && <p className="mb-3 text-destructive text-sm">{error}</p>}
253
+ {synonyms.length === 0 ? (
254
+ <p className="py-4 text-center text-muted-foreground text-sm">
255
+ No synonyms configured yet.
256
+ </p>
257
+ ) : (
258
+ <div className="divide-y divide-border rounded-md border border-border">
259
+ {synonyms.map((syn) => (
260
+ <div
261
+ key={syn.id}
262
+ className="flex items-center justify-between px-4 py-2.5"
263
+ >
264
+ <div className="text-sm">
265
+ <span className="font-medium text-foreground">
266
+ {syn.term}
267
+ </span>
268
+ <span className="mx-2 text-muted-foreground">&rarr;</span>
269
+ <span className="text-muted-foreground">
270
+ {syn.synonyms.join(", ")}
271
+ </span>
272
+ </div>
273
+ <button
274
+ type="button"
275
+ onClick={() =>
276
+ removeMutation.mutate({
277
+ params: { id: syn.id },
278
+ })
279
+ }
280
+ className="text-muted-foreground text-xs hover:text-destructive"
281
+ >
282
+ Remove
283
+ </button>
284
+ </div>
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+ </div>
290
+ </div>
291
+ );
292
+ }
@@ -0,0 +1,15 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const analyticsEndpoint = createAdminEndpoint(
5
+ "/admin/search/analytics",
6
+ { method: "GET" },
7
+ async (ctx) => {
8
+ const controller = ctx.context.controllers.search as SearchController;
9
+ const [analytics, indexCount] = await Promise.all([
10
+ controller.getAnalytics(),
11
+ controller.getIndexCount(),
12
+ ]);
13
+ return { analytics: { ...analytics, indexedItems: indexCount } };
14
+ },
15
+ );
@@ -0,0 +1,43 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const indexItem = createAdminEndpoint(
5
+ "/admin/search/index",
6
+ {
7
+ method: "POST",
8
+ body: z.object({
9
+ entityType: z.string().min(1).max(100),
10
+ entityId: z.string().min(1).max(200),
11
+ title: z.string().min(1).max(500),
12
+ body: z.string().max(10000).optional(),
13
+ tags: z.array(z.string().max(100)).max(50).optional(),
14
+ url: z.string().min(1).max(500),
15
+ image: z.string().max(500).optional(),
16
+ metadata: z.record(z.string(), z.unknown()).optional(),
17
+ }),
18
+ },
19
+ async (ctx) => {
20
+ const controller = ctx.context.controllers.search as SearchController;
21
+ const item = await controller.indexItem(ctx.body);
22
+ return { item };
23
+ },
24
+ );
25
+
26
+ export const removeFromIndex = createAdminEndpoint(
27
+ "/admin/search/index/remove",
28
+ {
29
+ method: "POST",
30
+ body: z.object({
31
+ entityType: z.string().min(1).max(100),
32
+ entityId: z.string().min(1).max(200),
33
+ }),
34
+ },
35
+ async (ctx) => {
36
+ const controller = ctx.context.controllers.search as SearchController;
37
+ const removed = await controller.removeFromIndex(
38
+ ctx.body.entityType,
39
+ ctx.body.entityId,
40
+ );
41
+ return { removed };
42
+ },
43
+ );
@@ -0,0 +1,16 @@
1
+ import { analyticsEndpoint } from "./analytics";
2
+ import { indexItem, removeFromIndex } from "./index-manage";
3
+ import { popularEndpoint } from "./popular";
4
+ import { addSynonym, listSynonyms, removeSynonym } from "./synonyms";
5
+ import { zeroResultsEndpoint } from "./zero-results";
6
+
7
+ export const adminEndpoints = {
8
+ "/admin/search/analytics": analyticsEndpoint,
9
+ "/admin/search/popular": popularEndpoint,
10
+ "/admin/search/zero-results": zeroResultsEndpoint,
11
+ "/admin/search/synonyms": listSynonyms,
12
+ "/admin/search/synonyms/add": addSynonym,
13
+ "/admin/search/synonyms/:id/delete": removeSynonym,
14
+ "/admin/search/index": indexItem,
15
+ "/admin/search/index/remove": removeFromIndex,
16
+ };
@@ -0,0 +1,17 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const popularEndpoint = createAdminEndpoint(
5
+ "/admin/search/popular",
6
+ {
7
+ method: "GET",
8
+ query: z.object({
9
+ limit: z.coerce.number().int().min(1).max(100).optional(),
10
+ }),
11
+ },
12
+ async (ctx) => {
13
+ const controller = ctx.context.controllers.search as SearchController;
14
+ const terms = await controller.getPopularTerms(ctx.query.limit ?? 20);
15
+ return { terms };
16
+ },
17
+ );
@@ -0,0 +1,49 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const listSynonyms = createAdminEndpoint(
5
+ "/admin/search/synonyms",
6
+ { method: "GET" },
7
+ async (ctx) => {
8
+ const controller = ctx.context.controllers.search as SearchController;
9
+ const synonyms = await controller.listSynonyms();
10
+ return { synonyms };
11
+ },
12
+ );
13
+
14
+ export const addSynonym = createAdminEndpoint(
15
+ "/admin/search/synonyms/add",
16
+ {
17
+ method: "POST",
18
+ body: z.object({
19
+ term: z.string().min(1).max(200),
20
+ synonyms: z.array(z.string().min(1).max(200)).min(1).max(50),
21
+ }),
22
+ },
23
+ async (ctx) => {
24
+ const controller = ctx.context.controllers.search as SearchController;
25
+ const synonym = await controller.addSynonym(
26
+ ctx.body.term,
27
+ ctx.body.synonyms,
28
+ );
29
+ return { synonym };
30
+ },
31
+ );
32
+
33
+ export const removeSynonym = createAdminEndpoint(
34
+ "/admin/search/synonyms/:id/delete",
35
+ {
36
+ method: "POST",
37
+ params: z.object({
38
+ id: z.string(),
39
+ }),
40
+ },
41
+ async (ctx) => {
42
+ const controller = ctx.context.controllers.search as SearchController;
43
+ const removed = await controller.removeSynonym(ctx.params.id);
44
+ if (!removed) {
45
+ throw new Error("Synonym not found");
46
+ }
47
+ return { success: true };
48
+ },
49
+ );
@@ -0,0 +1,17 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const zeroResultsEndpoint = createAdminEndpoint(
5
+ "/admin/search/zero-results",
6
+ {
7
+ method: "GET",
8
+ query: z.object({
9
+ limit: z.coerce.number().int().min(1).max(100).optional(),
10
+ }),
11
+ },
12
+ async (ctx) => {
13
+ const controller = ctx.context.controllers.search as SearchController;
14
+ const terms = await controller.getZeroResultQueries(ctx.query.limit ?? 20);
15
+ return { terms };
16
+ },
17
+ );
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Module, ModuleConfig, ModuleContext } from "@86d-app/core";
2
+ import { adminEndpoints } from "./admin/endpoints";
3
+ import { searchSchema } from "./schema";
4
+ import { createSearchController } from "./service-impl";
5
+ import { storeEndpoints } from "./store/endpoints";
6
+
7
+ export type {
8
+ SearchController,
9
+ SearchIndexItem,
10
+ SearchQuery,
11
+ SearchResult,
12
+ SearchSynonym,
13
+ } from "./service";
14
+
15
+ export interface SearchOptions extends ModuleConfig {
16
+ /** Maximum number of search results per query */
17
+ maxResults?: number;
18
+ }
19
+
20
+ export default function search(options?: SearchOptions): Module {
21
+ return {
22
+ id: "search",
23
+ version: "0.0.1",
24
+ schema: searchSchema,
25
+ exports: {
26
+ read: ["searchIndexCount", "popularTerms"],
27
+ },
28
+ events: {
29
+ emits: ["search.queried", "search.indexed", "search.removed"],
30
+ },
31
+ init: async (ctx: ModuleContext) => {
32
+ const controller = createSearchController(ctx.data);
33
+ return { controllers: { search: controller } };
34
+ },
35
+ search: { store: "/search/store-search" },
36
+ endpoints: {
37
+ store: storeEndpoints,
38
+ admin: adminEndpoints,
39
+ },
40
+ admin: {
41
+ pages: [
42
+ {
43
+ path: "/admin/search",
44
+ component: "SearchAnalytics",
45
+ label: "Search",
46
+ icon: "MagnifyingGlass",
47
+ group: "Marketing",
48
+ },
49
+ ],
50
+ },
51
+ store: {
52
+ pages: [
53
+ {
54
+ path: "/search",
55
+ component: "SearchPage",
56
+ },
57
+ ],
58
+ },
59
+ options,
60
+ };
61
+ }
package/src/mdx.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module "*.mdx" {
2
+ import type { ComponentType } from "react";
3
+ const Component: ComponentType<Record<string, unknown>>;
4
+ export default Component;
5
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { ModuleSchema } from "@86d-app/core";
2
+
3
+ export const searchSchema = {
4
+ searchIndex: {
5
+ fields: {
6
+ id: { type: "string", required: true },
7
+ entityType: { type: "string", required: true },
8
+ entityId: { type: "string", required: true },
9
+ title: { type: "string", required: true },
10
+ body: { type: "string", required: false },
11
+ tags: { type: "json", required: false, defaultValue: [] },
12
+ url: { type: "string", required: true },
13
+ image: { type: "string", required: false },
14
+ metadata: { type: "json", required: false, defaultValue: {} },
15
+ indexedAt: {
16
+ type: "date",
17
+ required: true,
18
+ defaultValue: () => new Date(),
19
+ },
20
+ },
21
+ },
22
+ searchQuery: {
23
+ fields: {
24
+ id: { type: "string", required: true },
25
+ term: { type: "string", required: true },
26
+ normalizedTerm: { type: "string", required: true },
27
+ resultCount: { type: "number", required: true },
28
+ sessionId: { type: "string", required: false },
29
+ searchedAt: {
30
+ type: "date",
31
+ required: true,
32
+ defaultValue: () => new Date(),
33
+ },
34
+ },
35
+ },
36
+ searchSynonym: {
37
+ fields: {
38
+ id: { type: "string", required: true },
39
+ term: { type: "string", required: true },
40
+ synonyms: { type: "json", required: true },
41
+ createdAt: {
42
+ type: "date",
43
+ required: true,
44
+ defaultValue: () => new Date(),
45
+ },
46
+ },
47
+ },
48
+ } satisfies ModuleSchema;