@huskel/sdk 0.2.0 → 0.2.2
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/README.md +121 -96
- package/dist/index.d.mts +50 -6
- package/dist/index.d.ts +50 -6
- package/dist/index.js +249 -47
- package/dist/index.mjs +225 -24
- package/package.json +15 -9
package/README.md
CHANGED
|
@@ -1,96 +1,121 @@
|
|
|
1
|
-
# @huskel/sdk
|
|
2
|
-
|
|
3
|
-
AI-powered vector search SDK. You own your data — pass it in, we handle the rest.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @huskel/sdk
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Setup
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}, [
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
```tsx
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
# @huskel/sdk
|
|
2
|
+
|
|
3
|
+
AI-powered vector search SDK. You own your data — pass it in, we handle the rest.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @huskel/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Wrap your application in the `<HuskelProvider>` (it uses `"use client"` internally, allowing your root layout to remain a Next.js Server Component):
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
// app/layout.tsx (Next.js Root Layout - Server Component)
|
|
17
|
+
import { HuskelProvider } from '@huskel/sdk';
|
|
18
|
+
|
|
19
|
+
export default function RootLayout({ children }) {
|
|
20
|
+
return (
|
|
21
|
+
<html>
|
|
22
|
+
<body>
|
|
23
|
+
{/* siteId, apiUrl, and apiToken are read automatically from NEXT_PUBLIC_HUSKEL_* env variables */}
|
|
24
|
+
<HuskelProvider>
|
|
25
|
+
{children}
|
|
26
|
+
</HuskelProvider>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
*Or pass configuration explicitly if you are not using environment variables:*
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<HuskelProvider
|
|
37
|
+
siteId="your-site-id"
|
|
38
|
+
apiUrl="https://your-huskel-backend.com"
|
|
39
|
+
apiToken="your-api-token"
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</HuskelProvider>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Ingest products (forgiving schema mapping)
|
|
46
|
+
|
|
47
|
+
Pass your raw database or CMS objects directly. The SDK automatically validates, dedupes, batches, and resolves common field naming variations (e.g. `title`/`name`, `thumbnail`/`image`/`images`, `slug`/`id`/`productId`):
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { useEffect } from 'react';
|
|
51
|
+
import { useIngest } from '@huskel/sdk';
|
|
52
|
+
|
|
53
|
+
// Single product page
|
|
54
|
+
export function ProductPage({ product }) {
|
|
55
|
+
const { ingest } = useIngest();
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
// Passes raw product object directly.
|
|
59
|
+
// Handles background batching, client-side deduplication, and offline recovery automatically.
|
|
60
|
+
ingest(product);
|
|
61
|
+
}, [product.id, ingest]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Listing / category page
|
|
65
|
+
export function ProductGrid({ products }) {
|
|
66
|
+
const { ingestBatch } = useIngest();
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
// Ingest array of products in a single debounced batch
|
|
70
|
+
ingestBatch(products);
|
|
71
|
+
}, [products, ingestBatch]);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Search
|
|
76
|
+
|
|
77
|
+
### SearchBar Dropdown Component
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { SearchBar } from '@huskel/sdk';
|
|
81
|
+
|
|
82
|
+
export function Header() {
|
|
83
|
+
return (
|
|
84
|
+
<SearchBar
|
|
85
|
+
onSelect={(result) => router.push(result.product.url)}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Headless Search Hook
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import { useSearch } from '@huskel/sdk';
|
|
95
|
+
|
|
96
|
+
export function CustomSearch() {
|
|
97
|
+
const { results, loading, search } = useSearch();
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div>
|
|
101
|
+
<input onChange={e => search(e.target.value)} />
|
|
102
|
+
<ul>
|
|
103
|
+
{results.map(r => (
|
|
104
|
+
<li key={r.id}>{r.product.name}</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Sparkle (similar products)
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { Sparkle } from '@huskel/sdk';
|
|
116
|
+
|
|
117
|
+
<Sparkle
|
|
118
|
+
productName={product.name}
|
|
119
|
+
onResult={(similar) => setSimilar(similar)}
|
|
120
|
+
/>
|
|
121
|
+
```
|
package/dist/index.d.mts
CHANGED
|
@@ -22,10 +22,37 @@ interface Product {
|
|
|
22
22
|
priceNumeric?: number;
|
|
23
23
|
slug?: string;
|
|
24
24
|
}
|
|
25
|
+
interface RawProductInput {
|
|
26
|
+
name?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
productName?: string;
|
|
29
|
+
price?: string | number;
|
|
30
|
+
priceNumeric?: number;
|
|
31
|
+
url?: string;
|
|
32
|
+
image?: string;
|
|
33
|
+
thumbnail?: string;
|
|
34
|
+
images?: string[];
|
|
35
|
+
slug?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
productId?: string;
|
|
38
|
+
brand?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
originalPrice?: string;
|
|
41
|
+
discount?: string;
|
|
42
|
+
currency?: string;
|
|
43
|
+
stock?: string;
|
|
44
|
+
availability?: string;
|
|
45
|
+
rating?: string;
|
|
46
|
+
reviewCount?: number;
|
|
47
|
+
category?: string;
|
|
48
|
+
subCategory?: string;
|
|
49
|
+
tags?: string[];
|
|
50
|
+
specs?: Record<string, string>;
|
|
51
|
+
}
|
|
25
52
|
interface HuskelConfig {
|
|
26
|
-
siteId
|
|
27
|
-
apiUrl
|
|
28
|
-
apiToken
|
|
53
|
+
siteId?: string;
|
|
54
|
+
apiUrl?: string;
|
|
55
|
+
apiToken?: string;
|
|
29
56
|
}
|
|
30
57
|
interface SearchRequest {
|
|
31
58
|
query: string;
|
|
@@ -64,11 +91,23 @@ declare class HuskelAPI {
|
|
|
64
91
|
|
|
65
92
|
declare class HuskelClient {
|
|
66
93
|
readonly api: HuskelAPI;
|
|
94
|
+
private ingestQueue;
|
|
95
|
+
private ingestTimer;
|
|
96
|
+
private ingestedUrls;
|
|
97
|
+
private onlineHandler;
|
|
67
98
|
constructor(config: HuskelConfig);
|
|
99
|
+
destroy(): void;
|
|
100
|
+
queueIngest(rawProduct: RawProductInput): Promise<void>;
|
|
101
|
+
queueIngestBatch(rawProducts: RawProductInput[]): Promise<void>;
|
|
102
|
+
private scheduleFlush;
|
|
103
|
+
private flushQueue;
|
|
68
104
|
}
|
|
69
105
|
declare function initHuskel(config: HuskelConfig): HuskelClient;
|
|
70
106
|
declare function getHuskelClient(): HuskelClient;
|
|
71
107
|
|
|
108
|
+
/**
|
|
109
|
+
* @deprecated Use <HuskelProvider> instead to avoid SSR issues.
|
|
110
|
+
*/
|
|
72
111
|
declare function useHuskel(config: HuskelConfig): HuskelClient;
|
|
73
112
|
|
|
74
113
|
interface UseSearchReturn {
|
|
@@ -81,8 +120,8 @@ interface UseSearchReturn {
|
|
|
81
120
|
declare function useSearch(): UseSearchReturn;
|
|
82
121
|
|
|
83
122
|
interface UseIngestReturn {
|
|
84
|
-
ingest: (product:
|
|
85
|
-
ingestBatch: (products:
|
|
123
|
+
ingest: (product: RawProductInput) => Promise<void>;
|
|
124
|
+
ingestBatch: (products: RawProductInput[]) => Promise<void>;
|
|
86
125
|
loading: boolean;
|
|
87
126
|
error: string | null;
|
|
88
127
|
}
|
|
@@ -108,4 +147,9 @@ interface SparkleProps {
|
|
|
108
147
|
}
|
|
109
148
|
declare function Sparkle({ productName, limit, onResult, className }: SparkleProps): react_jsx_runtime.JSX.Element;
|
|
110
149
|
|
|
111
|
-
|
|
150
|
+
interface HuskelProviderProps extends HuskelConfig {
|
|
151
|
+
children: React.ReactNode;
|
|
152
|
+
}
|
|
153
|
+
declare function HuskelProvider({ siteId, apiUrl, apiToken, children }: HuskelProviderProps): react_jsx_runtime.JSX.Element;
|
|
154
|
+
|
|
155
|
+
export { HuskelAPI, HuskelClient, type HuskelConfig, type HuskelError, HuskelProvider, type IngestResponse, type Product, type RawProductInput, SearchBar, type SearchRequest, type SearchResponse, type SearchResult, Sparkle, getHuskelClient, initHuskel, useHuskel, useIngest, useSearch };
|
package/dist/index.d.ts
CHANGED
|
@@ -22,10 +22,37 @@ interface Product {
|
|
|
22
22
|
priceNumeric?: number;
|
|
23
23
|
slug?: string;
|
|
24
24
|
}
|
|
25
|
+
interface RawProductInput {
|
|
26
|
+
name?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
productName?: string;
|
|
29
|
+
price?: string | number;
|
|
30
|
+
priceNumeric?: number;
|
|
31
|
+
url?: string;
|
|
32
|
+
image?: string;
|
|
33
|
+
thumbnail?: string;
|
|
34
|
+
images?: string[];
|
|
35
|
+
slug?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
productId?: string;
|
|
38
|
+
brand?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
originalPrice?: string;
|
|
41
|
+
discount?: string;
|
|
42
|
+
currency?: string;
|
|
43
|
+
stock?: string;
|
|
44
|
+
availability?: string;
|
|
45
|
+
rating?: string;
|
|
46
|
+
reviewCount?: number;
|
|
47
|
+
category?: string;
|
|
48
|
+
subCategory?: string;
|
|
49
|
+
tags?: string[];
|
|
50
|
+
specs?: Record<string, string>;
|
|
51
|
+
}
|
|
25
52
|
interface HuskelConfig {
|
|
26
|
-
siteId
|
|
27
|
-
apiUrl
|
|
28
|
-
apiToken
|
|
53
|
+
siteId?: string;
|
|
54
|
+
apiUrl?: string;
|
|
55
|
+
apiToken?: string;
|
|
29
56
|
}
|
|
30
57
|
interface SearchRequest {
|
|
31
58
|
query: string;
|
|
@@ -64,11 +91,23 @@ declare class HuskelAPI {
|
|
|
64
91
|
|
|
65
92
|
declare class HuskelClient {
|
|
66
93
|
readonly api: HuskelAPI;
|
|
94
|
+
private ingestQueue;
|
|
95
|
+
private ingestTimer;
|
|
96
|
+
private ingestedUrls;
|
|
97
|
+
private onlineHandler;
|
|
67
98
|
constructor(config: HuskelConfig);
|
|
99
|
+
destroy(): void;
|
|
100
|
+
queueIngest(rawProduct: RawProductInput): Promise<void>;
|
|
101
|
+
queueIngestBatch(rawProducts: RawProductInput[]): Promise<void>;
|
|
102
|
+
private scheduleFlush;
|
|
103
|
+
private flushQueue;
|
|
68
104
|
}
|
|
69
105
|
declare function initHuskel(config: HuskelConfig): HuskelClient;
|
|
70
106
|
declare function getHuskelClient(): HuskelClient;
|
|
71
107
|
|
|
108
|
+
/**
|
|
109
|
+
* @deprecated Use <HuskelProvider> instead to avoid SSR issues.
|
|
110
|
+
*/
|
|
72
111
|
declare function useHuskel(config: HuskelConfig): HuskelClient;
|
|
73
112
|
|
|
74
113
|
interface UseSearchReturn {
|
|
@@ -81,8 +120,8 @@ interface UseSearchReturn {
|
|
|
81
120
|
declare function useSearch(): UseSearchReturn;
|
|
82
121
|
|
|
83
122
|
interface UseIngestReturn {
|
|
84
|
-
ingest: (product:
|
|
85
|
-
ingestBatch: (products:
|
|
123
|
+
ingest: (product: RawProductInput) => Promise<void>;
|
|
124
|
+
ingestBatch: (products: RawProductInput[]) => Promise<void>;
|
|
86
125
|
loading: boolean;
|
|
87
126
|
error: string | null;
|
|
88
127
|
}
|
|
@@ -108,4 +147,9 @@ interface SparkleProps {
|
|
|
108
147
|
}
|
|
109
148
|
declare function Sparkle({ productName, limit, onResult, className }: SparkleProps): react_jsx_runtime.JSX.Element;
|
|
110
149
|
|
|
111
|
-
|
|
150
|
+
interface HuskelProviderProps extends HuskelConfig {
|
|
151
|
+
children: React.ReactNode;
|
|
152
|
+
}
|
|
153
|
+
declare function HuskelProvider({ siteId, apiUrl, apiToken, children }: HuskelProviderProps): react_jsx_runtime.JSX.Element;
|
|
154
|
+
|
|
155
|
+
export { HuskelAPI, HuskelClient, type HuskelConfig, type HuskelError, HuskelProvider, type IngestResponse, type Product, type RawProductInput, SearchBar, type SearchRequest, type SearchResponse, type SearchResult, Sparkle, getHuskelClient, initHuskel, useHuskel, useIngest, useSearch };
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
HuskelAPI: () => HuskelAPI,
|
|
24
24
|
HuskelClient: () => HuskelClient,
|
|
25
|
+
HuskelProvider: () => HuskelProvider,
|
|
25
26
|
SearchBar: () => SearchBar,
|
|
26
27
|
Sparkle: () => Sparkle,
|
|
27
28
|
getHuskelClient: () => getHuskelClient,
|
|
@@ -105,9 +106,169 @@ var HuskelAPI = class {
|
|
|
105
106
|
};
|
|
106
107
|
|
|
107
108
|
// src/client.ts
|
|
109
|
+
function getEnvVar(key) {
|
|
110
|
+
if (typeof globalThis !== "undefined") {
|
|
111
|
+
const g = globalThis;
|
|
112
|
+
if (g.process && g.process.env) {
|
|
113
|
+
return g.process.env[key];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
function mapRawProduct(input) {
|
|
119
|
+
var _a;
|
|
120
|
+
const name = input.name || input.title || input.productName || "";
|
|
121
|
+
let price = "";
|
|
122
|
+
let priceNumeric = void 0;
|
|
123
|
+
if (input.price !== void 0) {
|
|
124
|
+
if (typeof input.price === "number") {
|
|
125
|
+
priceNumeric = input.price;
|
|
126
|
+
price = String(input.price);
|
|
127
|
+
} else {
|
|
128
|
+
price = input.price;
|
|
129
|
+
const num = parseFloat(input.price.replace(/[^0-9.]/g, ""));
|
|
130
|
+
priceNumeric = isNaN(num) ? void 0 : num;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (input.priceNumeric !== void 0) {
|
|
134
|
+
priceNumeric = input.priceNumeric;
|
|
135
|
+
}
|
|
136
|
+
let url = input.url || "";
|
|
137
|
+
if (!url && typeof window !== "undefined") {
|
|
138
|
+
url = window.location.href;
|
|
139
|
+
}
|
|
140
|
+
let slug = input.slug || input.id || input.productId || "";
|
|
141
|
+
if (!slug && url) {
|
|
142
|
+
slug = url.split("/").filter(Boolean).pop() || "";
|
|
143
|
+
}
|
|
144
|
+
if (!slug && name) {
|
|
145
|
+
slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
146
|
+
}
|
|
147
|
+
let images = [];
|
|
148
|
+
if (input.images) {
|
|
149
|
+
images = input.images;
|
|
150
|
+
} else if (input.image) {
|
|
151
|
+
images = [input.image];
|
|
152
|
+
} else if (input.thumbnail) {
|
|
153
|
+
images = [input.thumbnail];
|
|
154
|
+
}
|
|
155
|
+
if (!name) {
|
|
156
|
+
console.warn("[Huskel] Validation warning: Product name/title is missing. Skipping:", input);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (!price) {
|
|
160
|
+
console.warn("[Huskel] Validation warning: Product price is missing. Skipping:", input);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!url) {
|
|
164
|
+
console.warn("[Huskel] Validation warning: Product URL is missing. Skipping:", input);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
name,
|
|
169
|
+
price,
|
|
170
|
+
url,
|
|
171
|
+
brand: input.brand,
|
|
172
|
+
description: input.description,
|
|
173
|
+
originalPrice: input.originalPrice,
|
|
174
|
+
discount: input.discount,
|
|
175
|
+
currency: (_a = input.currency) != null ? _a : "KES",
|
|
176
|
+
stock: input.stock,
|
|
177
|
+
availability: input.availability,
|
|
178
|
+
rating: input.rating,
|
|
179
|
+
reviewCount: input.reviewCount,
|
|
180
|
+
category: input.category,
|
|
181
|
+
subCategory: input.subCategory,
|
|
182
|
+
tags: input.tags,
|
|
183
|
+
images: images.length > 0 ? images : void 0,
|
|
184
|
+
specs: input.specs,
|
|
185
|
+
priceNumeric,
|
|
186
|
+
slug
|
|
187
|
+
};
|
|
188
|
+
}
|
|
108
189
|
var HuskelClient = class {
|
|
109
190
|
constructor(config) {
|
|
110
|
-
this.
|
|
191
|
+
this.ingestQueue = [];
|
|
192
|
+
this.ingestTimer = null;
|
|
193
|
+
this.ingestedUrls = /* @__PURE__ */ new Set();
|
|
194
|
+
this.onlineHandler = null;
|
|
195
|
+
const siteId = config.siteId || getEnvVar("NEXT_PUBLIC_HUSKEL_SITE_ID") || "";
|
|
196
|
+
const apiUrl = config.apiUrl || getEnvVar("NEXT_PUBLIC_HUSKEL_API_URL") || "";
|
|
197
|
+
const apiToken = config.apiToken || getEnvVar("NEXT_PUBLIC_HUSKEL_API_TOKEN") || "";
|
|
198
|
+
if (!siteId) console.error('[Huskel] Missing siteId. Set it via <HuskelProvider siteId="..."> or NEXT_PUBLIC_HUSKEL_SITE_ID.');
|
|
199
|
+
if (!apiUrl) console.error('[Huskel] Missing apiUrl. Set it via <HuskelProvider apiUrl="..."> or NEXT_PUBLIC_HUSKEL_API_URL.');
|
|
200
|
+
if (!apiToken) console.error('[Huskel] Missing apiToken. Set it via <HuskelProvider apiToken="..."> or NEXT_PUBLIC_HUSKEL_API_TOKEN.');
|
|
201
|
+
this.api = new HuskelAPI(apiUrl, siteId, apiToken);
|
|
202
|
+
instance = this;
|
|
203
|
+
if (typeof window !== "undefined") {
|
|
204
|
+
this.onlineHandler = () => {
|
|
205
|
+
console.log("[Huskel] Connectivity restored, flushing queued ingestions.");
|
|
206
|
+
this.flushQueue();
|
|
207
|
+
};
|
|
208
|
+
window.addEventListener("online", this.onlineHandler);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
destroy() {
|
|
212
|
+
if (typeof window !== "undefined" && this.onlineHandler) {
|
|
213
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
214
|
+
this.onlineHandler = null;
|
|
215
|
+
}
|
|
216
|
+
if (this.ingestTimer) {
|
|
217
|
+
clearTimeout(this.ingestTimer);
|
|
218
|
+
this.ingestTimer = null;
|
|
219
|
+
}
|
|
220
|
+
if (instance === this) instance = null;
|
|
221
|
+
}
|
|
222
|
+
async queueIngest(rawProduct) {
|
|
223
|
+
const product = mapRawProduct(rawProduct);
|
|
224
|
+
if (!product) return;
|
|
225
|
+
if (this.ingestedUrls.has(product.url)) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.ingestedUrls.add(product.url);
|
|
229
|
+
this.ingestQueue.push(product);
|
|
230
|
+
this.scheduleFlush();
|
|
231
|
+
}
|
|
232
|
+
async queueIngestBatch(rawProducts) {
|
|
233
|
+
rawProducts.forEach((p) => {
|
|
234
|
+
const product = mapRawProduct(p);
|
|
235
|
+
if (!product) return;
|
|
236
|
+
if (this.ingestedUrls.has(product.url)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.ingestedUrls.add(product.url);
|
|
240
|
+
this.ingestQueue.push(product);
|
|
241
|
+
});
|
|
242
|
+
if (this.ingestQueue.length > 0) {
|
|
243
|
+
this.scheduleFlush();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
scheduleFlush() {
|
|
247
|
+
if (this.ingestTimer) return;
|
|
248
|
+
this.ingestTimer = setTimeout(() => {
|
|
249
|
+
this.flushQueue();
|
|
250
|
+
}, 300);
|
|
251
|
+
}
|
|
252
|
+
async flushQueue() {
|
|
253
|
+
this.ingestTimer = null;
|
|
254
|
+
if (this.ingestQueue.length === 0) return;
|
|
255
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
256
|
+
console.warn("[Huskel] Browser offline. Postponing ingestion.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const batch = [...this.ingestQueue];
|
|
260
|
+
this.ingestQueue = [];
|
|
261
|
+
try {
|
|
262
|
+
await this.api.ingestBatch(batch);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e.status && e.status >= 400 && e.status < 500) {
|
|
265
|
+
console.error("[Huskel] Ingestion discarded due to client error:", e.message);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
console.warn("[Huskel] Ingestion failed. Re-queuing to retry.", e);
|
|
269
|
+
this.ingestQueue = [...batch, ...this.ingestQueue];
|
|
270
|
+
this.scheduleFlush();
|
|
271
|
+
}
|
|
111
272
|
}
|
|
112
273
|
};
|
|
113
274
|
var instance = null;
|
|
@@ -116,7 +277,16 @@ function initHuskel(config) {
|
|
|
116
277
|
return instance;
|
|
117
278
|
}
|
|
118
279
|
function getHuskelClient() {
|
|
119
|
-
if (!instance)
|
|
280
|
+
if (!instance) {
|
|
281
|
+
const siteId = getEnvVar("NEXT_PUBLIC_HUSKEL_SITE_ID");
|
|
282
|
+
const apiUrl = getEnvVar("NEXT_PUBLIC_HUSKEL_API_URL");
|
|
283
|
+
const apiToken = getEnvVar("NEXT_PUBLIC_HUSKEL_API_TOKEN");
|
|
284
|
+
if (siteId && apiUrl && apiToken) {
|
|
285
|
+
instance = new HuskelClient({ siteId, apiUrl, apiToken });
|
|
286
|
+
} else {
|
|
287
|
+
throw new Error("[Huskel] Call initHuskel() or set NEXT_PUBLIC_HUSKEL_* environment variables before using the client.");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
120
290
|
return instance;
|
|
121
291
|
}
|
|
122
292
|
|
|
@@ -125,19 +295,48 @@ var import_react = require("react");
|
|
|
125
295
|
function useHuskel(config) {
|
|
126
296
|
const clientRef = (0, import_react.useRef)(null);
|
|
127
297
|
if (!clientRef.current) {
|
|
298
|
+
console.warn("[Huskel] useHuskel() is deprecated. Please wrap your application in <HuskelProvider> instead.");
|
|
128
299
|
clientRef.current = initHuskel(config);
|
|
129
300
|
}
|
|
130
301
|
return clientRef.current;
|
|
131
302
|
}
|
|
132
303
|
|
|
133
304
|
// src/hooks/useSearch.ts
|
|
305
|
+
var import_react3 = require("react");
|
|
306
|
+
|
|
307
|
+
// src/components/HuskelProvider.tsx
|
|
134
308
|
var import_react2 = require("react");
|
|
309
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
310
|
+
var HuskelContext = (0, import_react2.createContext)(null);
|
|
311
|
+
function HuskelProvider({ siteId, apiUrl, apiToken, children }) {
|
|
312
|
+
const clientRef = (0, import_react2.useRef)(null);
|
|
313
|
+
if (!clientRef.current) {
|
|
314
|
+
clientRef.current = new HuskelClient({ siteId, apiUrl, apiToken });
|
|
315
|
+
}
|
|
316
|
+
(0, import_react2.useEffect)(() => {
|
|
317
|
+
return () => {
|
|
318
|
+
var _a;
|
|
319
|
+
(_a = clientRef.current) == null ? void 0 : _a.destroy();
|
|
320
|
+
};
|
|
321
|
+
}, []);
|
|
322
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HuskelContext.Provider, { value: clientRef.current, children });
|
|
323
|
+
}
|
|
324
|
+
function useHuskelContext() {
|
|
325
|
+
const context = (0, import_react2.useContext)(HuskelContext);
|
|
326
|
+
if (!context) {
|
|
327
|
+
return getHuskelClient();
|
|
328
|
+
}
|
|
329
|
+
return context;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/hooks/useSearch.ts
|
|
135
333
|
function useSearch() {
|
|
136
|
-
const
|
|
137
|
-
const [
|
|
138
|
-
const [
|
|
139
|
-
const
|
|
140
|
-
const
|
|
334
|
+
const client = useHuskelContext();
|
|
335
|
+
const [results, setResults] = (0, import_react3.useState)([]);
|
|
336
|
+
const [loading, setLoading] = (0, import_react3.useState)(false);
|
|
337
|
+
const [error, setError] = (0, import_react3.useState)(null);
|
|
338
|
+
const abortRef = (0, import_react3.useRef)(null);
|
|
339
|
+
const search = (0, import_react3.useCallback)(async (query, limit = 10) => {
|
|
141
340
|
var _a, _b, _c;
|
|
142
341
|
if (!query.trim()) {
|
|
143
342
|
setResults([]);
|
|
@@ -148,15 +347,15 @@ function useSearch() {
|
|
|
148
347
|
setLoading(true);
|
|
149
348
|
setError(null);
|
|
150
349
|
try {
|
|
151
|
-
const res = await
|
|
350
|
+
const res = await client.api.search(query, limit);
|
|
152
351
|
setResults((_b = res.results) != null ? _b : []);
|
|
153
352
|
} catch (e) {
|
|
154
353
|
setError((_c = e.message) != null ? _c : "Search failed");
|
|
155
354
|
} finally {
|
|
156
355
|
setLoading(false);
|
|
157
356
|
}
|
|
158
|
-
}, []);
|
|
159
|
-
const clear = (0,
|
|
357
|
+
}, [client]);
|
|
358
|
+
const clear = (0, import_react3.useCallback)(() => {
|
|
160
359
|
setResults([]);
|
|
161
360
|
setError(null);
|
|
162
361
|
}, []);
|
|
@@ -164,41 +363,42 @@ function useSearch() {
|
|
|
164
363
|
}
|
|
165
364
|
|
|
166
365
|
// src/hooks/useIngest.ts
|
|
167
|
-
var
|
|
366
|
+
var import_react4 = require("react");
|
|
168
367
|
function useIngest() {
|
|
169
|
-
const
|
|
170
|
-
const [
|
|
171
|
-
const
|
|
368
|
+
const client = useHuskelContext();
|
|
369
|
+
const [loading, setLoading] = (0, import_react4.useState)(false);
|
|
370
|
+
const [error, setError] = (0, import_react4.useState)(null);
|
|
371
|
+
const ingest = (0, import_react4.useCallback)(async (product) => {
|
|
172
372
|
var _a;
|
|
173
373
|
setLoading(true);
|
|
174
374
|
setError(null);
|
|
175
375
|
try {
|
|
176
|
-
await
|
|
376
|
+
await client.queueIngest(product);
|
|
177
377
|
} catch (e) {
|
|
178
378
|
setError((_a = e.message) != null ? _a : "Ingest failed");
|
|
179
379
|
} finally {
|
|
180
380
|
setLoading(false);
|
|
181
381
|
}
|
|
182
|
-
}, []);
|
|
183
|
-
const ingestBatch = (0,
|
|
382
|
+
}, [client]);
|
|
383
|
+
const ingestBatch = (0, import_react4.useCallback)(async (products) => {
|
|
184
384
|
var _a;
|
|
185
385
|
if (!products.length) return;
|
|
186
386
|
setLoading(true);
|
|
187
387
|
setError(null);
|
|
188
388
|
try {
|
|
189
|
-
await
|
|
389
|
+
await client.queueIngestBatch(products);
|
|
190
390
|
} catch (e) {
|
|
191
391
|
setError((_a = e.message) != null ? _a : "Batch ingest failed");
|
|
192
392
|
} finally {
|
|
193
393
|
setLoading(false);
|
|
194
394
|
}
|
|
195
|
-
}, []);
|
|
395
|
+
}, [client]);
|
|
196
396
|
return { ingest, ingestBatch, loading, error };
|
|
197
397
|
}
|
|
198
398
|
|
|
199
399
|
// src/components/SearchBar.tsx
|
|
200
|
-
var
|
|
201
|
-
var
|
|
400
|
+
var import_react5 = require("react");
|
|
401
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
202
402
|
var S = `
|
|
203
403
|
.hsk-wrap{position:relative;width:100%;font-family:inherit}
|
|
204
404
|
.hsk-input{width:100%;padding:10px 16px;font-size:15px;border:1.5px solid #e2e2e2;border-radius:8px;outline:none;box-sizing:border-box;background:#fff;transition:border-color .2s}
|
|
@@ -221,12 +421,12 @@ function SearchBar({
|
|
|
221
421
|
dropdownClassName,
|
|
222
422
|
renderResult
|
|
223
423
|
}) {
|
|
224
|
-
const [query, setQuery] = (0,
|
|
225
|
-
const [open, setOpen] = (0,
|
|
424
|
+
const [query, setQuery] = (0, import_react5.useState)("");
|
|
425
|
+
const [open, setOpen] = (0, import_react5.useState)(false);
|
|
226
426
|
const { results, loading, search, clear } = useSearch();
|
|
227
|
-
const timer = (0,
|
|
228
|
-
const wrap = (0,
|
|
229
|
-
(0,
|
|
427
|
+
const timer = (0, import_react5.useRef)();
|
|
428
|
+
const wrap = (0, import_react5.useRef)(null);
|
|
429
|
+
(0, import_react5.useEffect)(() => {
|
|
230
430
|
clearTimeout(timer.current);
|
|
231
431
|
if (!query.trim()) {
|
|
232
432
|
clear();
|
|
@@ -238,8 +438,8 @@ function SearchBar({
|
|
|
238
438
|
setOpen(true);
|
|
239
439
|
}, debounceMs);
|
|
240
440
|
return () => clearTimeout(timer.current);
|
|
241
|
-
}, [query]);
|
|
242
|
-
(0,
|
|
441
|
+
}, [query, search, clear, limit, debounceMs]);
|
|
442
|
+
(0, import_react5.useEffect)(() => {
|
|
243
443
|
const handler = (e) => {
|
|
244
444
|
if (wrap.current && !wrap.current.contains(e.target)) setOpen(false);
|
|
245
445
|
};
|
|
@@ -251,10 +451,10 @@ function SearchBar({
|
|
|
251
451
|
setQuery(r.product.name);
|
|
252
452
|
onSelect == null ? void 0 : onSelect(r);
|
|
253
453
|
};
|
|
254
|
-
return /* @__PURE__ */ (0,
|
|
255
|
-
/* @__PURE__ */ (0,
|
|
256
|
-
/* @__PURE__ */ (0,
|
|
257
|
-
/* @__PURE__ */ (0,
|
|
454
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
455
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("style", { children: S }),
|
|
456
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `hsk-wrap ${className != null ? className : ""}`, ref: wrap, children: [
|
|
457
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
258
458
|
"input",
|
|
259
459
|
{
|
|
260
460
|
className: `hsk-input ${inputClassName != null ? inputClassName : ""}`,
|
|
@@ -265,9 +465,9 @@ function SearchBar({
|
|
|
265
465
|
onFocus: () => results.length && setOpen(true)
|
|
266
466
|
}
|
|
267
467
|
),
|
|
268
|
-
open && /* @__PURE__ */ (0,
|
|
269
|
-
loading && /* @__PURE__ */ (0,
|
|
270
|
-
!loading && results.length === 0 && /* @__PURE__ */ (0,
|
|
468
|
+
open && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `hsk-drop ${dropdownClassName != null ? dropdownClassName : ""}`, children: [
|
|
469
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "hsk-msg", children: "Searching\u2026" }),
|
|
470
|
+
!loading && results.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "hsk-msg", children: [
|
|
271
471
|
'No results for "',
|
|
272
472
|
query,
|
|
273
473
|
'"'
|
|
@@ -275,11 +475,11 @@ function SearchBar({
|
|
|
275
475
|
results.map(
|
|
276
476
|
(r) => {
|
|
277
477
|
var _a, _b;
|
|
278
|
-
return renderResult ? /* @__PURE__ */ (0,
|
|
279
|
-
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */ (0,
|
|
280
|
-
/* @__PURE__ */ (0,
|
|
281
|
-
/* @__PURE__ */ (0,
|
|
282
|
-
/* @__PURE__ */ (0,
|
|
478
|
+
return renderResult ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { onClick: () => handleSelect(r), children: renderResult(r) }, r.id) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "hsk-item", onClick: () => handleSelect(r), children: [
|
|
479
|
+
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("img", { src: r.product.images[0], alt: r.product.name }),
|
|
480
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
|
|
481
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "hsk-item-name", children: r.product.name }),
|
|
482
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "hsk-item-price", children: [
|
|
283
483
|
(_b = r.product.currency) != null ? _b : "KES",
|
|
284
484
|
" ",
|
|
285
485
|
r.product.price
|
|
@@ -294,19 +494,20 @@ function SearchBar({
|
|
|
294
494
|
}
|
|
295
495
|
|
|
296
496
|
// src/components/Sparkle.tsx
|
|
297
|
-
var
|
|
298
|
-
var
|
|
497
|
+
var import_react6 = require("react");
|
|
498
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
299
499
|
var S2 = `
|
|
300
500
|
.hsk-sparkle{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;font-size:12px;font-weight:600;background:#f47c3c;color:#fff;border:none;border-radius:20px;cursor:pointer;transition:opacity .2s,transform .15s}
|
|
301
501
|
.hsk-sparkle:hover{opacity:.88;transform:scale(1.04)}
|
|
302
502
|
.hsk-sparkle:disabled{opacity:.5;cursor:not-allowed}
|
|
303
503
|
`;
|
|
304
504
|
function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
305
|
-
const
|
|
505
|
+
const client = useHuskelContext();
|
|
506
|
+
const [loading, setLoading] = (0, import_react6.useState)(false);
|
|
306
507
|
const handleClick = async () => {
|
|
307
508
|
setLoading(true);
|
|
308
509
|
try {
|
|
309
|
-
const res = await
|
|
510
|
+
const res = await client.api.search(productName, limit);
|
|
310
511
|
onResult == null ? void 0 : onResult(res.results);
|
|
311
512
|
} catch (e) {
|
|
312
513
|
console.error("[Huskel Sparkle]", e);
|
|
@@ -314,9 +515,9 @@ function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
|
314
515
|
setLoading(false);
|
|
315
516
|
}
|
|
316
517
|
};
|
|
317
|
-
return /* @__PURE__ */ (0,
|
|
318
|
-
/* @__PURE__ */ (0,
|
|
319
|
-
/* @__PURE__ */ (0,
|
|
518
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
519
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("style", { children: S2 }),
|
|
520
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("button", { className: `hsk-sparkle ${className != null ? className : ""}`, onClick: handleClick, disabled: loading, children: [
|
|
320
521
|
"\u2726 ",
|
|
321
522
|
loading ? "Finding\u2026" : "Similar"
|
|
322
523
|
] })
|
|
@@ -326,6 +527,7 @@ function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
|
326
527
|
0 && (module.exports = {
|
|
327
528
|
HuskelAPI,
|
|
328
529
|
HuskelClient,
|
|
530
|
+
HuskelProvider,
|
|
329
531
|
SearchBar,
|
|
330
532
|
Sparkle,
|
|
331
533
|
getHuskelClient,
|
package/dist/index.mjs
CHANGED
|
@@ -71,9 +71,169 @@ var HuskelAPI = class {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
// src/client.ts
|
|
74
|
+
function getEnvVar(key) {
|
|
75
|
+
if (typeof globalThis !== "undefined") {
|
|
76
|
+
const g = globalThis;
|
|
77
|
+
if (g.process && g.process.env) {
|
|
78
|
+
return g.process.env[key];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
function mapRawProduct(input) {
|
|
84
|
+
var _a;
|
|
85
|
+
const name = input.name || input.title || input.productName || "";
|
|
86
|
+
let price = "";
|
|
87
|
+
let priceNumeric = void 0;
|
|
88
|
+
if (input.price !== void 0) {
|
|
89
|
+
if (typeof input.price === "number") {
|
|
90
|
+
priceNumeric = input.price;
|
|
91
|
+
price = String(input.price);
|
|
92
|
+
} else {
|
|
93
|
+
price = input.price;
|
|
94
|
+
const num = parseFloat(input.price.replace(/[^0-9.]/g, ""));
|
|
95
|
+
priceNumeric = isNaN(num) ? void 0 : num;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (input.priceNumeric !== void 0) {
|
|
99
|
+
priceNumeric = input.priceNumeric;
|
|
100
|
+
}
|
|
101
|
+
let url = input.url || "";
|
|
102
|
+
if (!url && typeof window !== "undefined") {
|
|
103
|
+
url = window.location.href;
|
|
104
|
+
}
|
|
105
|
+
let slug = input.slug || input.id || input.productId || "";
|
|
106
|
+
if (!slug && url) {
|
|
107
|
+
slug = url.split("/").filter(Boolean).pop() || "";
|
|
108
|
+
}
|
|
109
|
+
if (!slug && name) {
|
|
110
|
+
slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
111
|
+
}
|
|
112
|
+
let images = [];
|
|
113
|
+
if (input.images) {
|
|
114
|
+
images = input.images;
|
|
115
|
+
} else if (input.image) {
|
|
116
|
+
images = [input.image];
|
|
117
|
+
} else if (input.thumbnail) {
|
|
118
|
+
images = [input.thumbnail];
|
|
119
|
+
}
|
|
120
|
+
if (!name) {
|
|
121
|
+
console.warn("[Huskel] Validation warning: Product name/title is missing. Skipping:", input);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!price) {
|
|
125
|
+
console.warn("[Huskel] Validation warning: Product price is missing. Skipping:", input);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (!url) {
|
|
129
|
+
console.warn("[Huskel] Validation warning: Product URL is missing. Skipping:", input);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
price,
|
|
135
|
+
url,
|
|
136
|
+
brand: input.brand,
|
|
137
|
+
description: input.description,
|
|
138
|
+
originalPrice: input.originalPrice,
|
|
139
|
+
discount: input.discount,
|
|
140
|
+
currency: (_a = input.currency) != null ? _a : "KES",
|
|
141
|
+
stock: input.stock,
|
|
142
|
+
availability: input.availability,
|
|
143
|
+
rating: input.rating,
|
|
144
|
+
reviewCount: input.reviewCount,
|
|
145
|
+
category: input.category,
|
|
146
|
+
subCategory: input.subCategory,
|
|
147
|
+
tags: input.tags,
|
|
148
|
+
images: images.length > 0 ? images : void 0,
|
|
149
|
+
specs: input.specs,
|
|
150
|
+
priceNumeric,
|
|
151
|
+
slug
|
|
152
|
+
};
|
|
153
|
+
}
|
|
74
154
|
var HuskelClient = class {
|
|
75
155
|
constructor(config) {
|
|
76
|
-
this.
|
|
156
|
+
this.ingestQueue = [];
|
|
157
|
+
this.ingestTimer = null;
|
|
158
|
+
this.ingestedUrls = /* @__PURE__ */ new Set();
|
|
159
|
+
this.onlineHandler = null;
|
|
160
|
+
const siteId = config.siteId || getEnvVar("NEXT_PUBLIC_HUSKEL_SITE_ID") || "";
|
|
161
|
+
const apiUrl = config.apiUrl || getEnvVar("NEXT_PUBLIC_HUSKEL_API_URL") || "";
|
|
162
|
+
const apiToken = config.apiToken || getEnvVar("NEXT_PUBLIC_HUSKEL_API_TOKEN") || "";
|
|
163
|
+
if (!siteId) console.error('[Huskel] Missing siteId. Set it via <HuskelProvider siteId="..."> or NEXT_PUBLIC_HUSKEL_SITE_ID.');
|
|
164
|
+
if (!apiUrl) console.error('[Huskel] Missing apiUrl. Set it via <HuskelProvider apiUrl="..."> or NEXT_PUBLIC_HUSKEL_API_URL.');
|
|
165
|
+
if (!apiToken) console.error('[Huskel] Missing apiToken. Set it via <HuskelProvider apiToken="..."> or NEXT_PUBLIC_HUSKEL_API_TOKEN.');
|
|
166
|
+
this.api = new HuskelAPI(apiUrl, siteId, apiToken);
|
|
167
|
+
instance = this;
|
|
168
|
+
if (typeof window !== "undefined") {
|
|
169
|
+
this.onlineHandler = () => {
|
|
170
|
+
console.log("[Huskel] Connectivity restored, flushing queued ingestions.");
|
|
171
|
+
this.flushQueue();
|
|
172
|
+
};
|
|
173
|
+
window.addEventListener("online", this.onlineHandler);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
destroy() {
|
|
177
|
+
if (typeof window !== "undefined" && this.onlineHandler) {
|
|
178
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
179
|
+
this.onlineHandler = null;
|
|
180
|
+
}
|
|
181
|
+
if (this.ingestTimer) {
|
|
182
|
+
clearTimeout(this.ingestTimer);
|
|
183
|
+
this.ingestTimer = null;
|
|
184
|
+
}
|
|
185
|
+
if (instance === this) instance = null;
|
|
186
|
+
}
|
|
187
|
+
async queueIngest(rawProduct) {
|
|
188
|
+
const product = mapRawProduct(rawProduct);
|
|
189
|
+
if (!product) return;
|
|
190
|
+
if (this.ingestedUrls.has(product.url)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.ingestedUrls.add(product.url);
|
|
194
|
+
this.ingestQueue.push(product);
|
|
195
|
+
this.scheduleFlush();
|
|
196
|
+
}
|
|
197
|
+
async queueIngestBatch(rawProducts) {
|
|
198
|
+
rawProducts.forEach((p) => {
|
|
199
|
+
const product = mapRawProduct(p);
|
|
200
|
+
if (!product) return;
|
|
201
|
+
if (this.ingestedUrls.has(product.url)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.ingestedUrls.add(product.url);
|
|
205
|
+
this.ingestQueue.push(product);
|
|
206
|
+
});
|
|
207
|
+
if (this.ingestQueue.length > 0) {
|
|
208
|
+
this.scheduleFlush();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
scheduleFlush() {
|
|
212
|
+
if (this.ingestTimer) return;
|
|
213
|
+
this.ingestTimer = setTimeout(() => {
|
|
214
|
+
this.flushQueue();
|
|
215
|
+
}, 300);
|
|
216
|
+
}
|
|
217
|
+
async flushQueue() {
|
|
218
|
+
this.ingestTimer = null;
|
|
219
|
+
if (this.ingestQueue.length === 0) return;
|
|
220
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
221
|
+
console.warn("[Huskel] Browser offline. Postponing ingestion.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const batch = [...this.ingestQueue];
|
|
225
|
+
this.ingestQueue = [];
|
|
226
|
+
try {
|
|
227
|
+
await this.api.ingestBatch(batch);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (e.status && e.status >= 400 && e.status < 500) {
|
|
230
|
+
console.error("[Huskel] Ingestion discarded due to client error:", e.message);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.warn("[Huskel] Ingestion failed. Re-queuing to retry.", e);
|
|
234
|
+
this.ingestQueue = [...batch, ...this.ingestQueue];
|
|
235
|
+
this.scheduleFlush();
|
|
236
|
+
}
|
|
77
237
|
}
|
|
78
238
|
};
|
|
79
239
|
var instance = null;
|
|
@@ -82,7 +242,16 @@ function initHuskel(config) {
|
|
|
82
242
|
return instance;
|
|
83
243
|
}
|
|
84
244
|
function getHuskelClient() {
|
|
85
|
-
if (!instance)
|
|
245
|
+
if (!instance) {
|
|
246
|
+
const siteId = getEnvVar("NEXT_PUBLIC_HUSKEL_SITE_ID");
|
|
247
|
+
const apiUrl = getEnvVar("NEXT_PUBLIC_HUSKEL_API_URL");
|
|
248
|
+
const apiToken = getEnvVar("NEXT_PUBLIC_HUSKEL_API_TOKEN");
|
|
249
|
+
if (siteId && apiUrl && apiToken) {
|
|
250
|
+
instance = new HuskelClient({ siteId, apiUrl, apiToken });
|
|
251
|
+
} else {
|
|
252
|
+
throw new Error("[Huskel] Call initHuskel() or set NEXT_PUBLIC_HUSKEL_* environment variables before using the client.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
86
255
|
return instance;
|
|
87
256
|
}
|
|
88
257
|
|
|
@@ -91,18 +260,47 @@ import { useRef } from "react";
|
|
|
91
260
|
function useHuskel(config) {
|
|
92
261
|
const clientRef = useRef(null);
|
|
93
262
|
if (!clientRef.current) {
|
|
263
|
+
console.warn("[Huskel] useHuskel() is deprecated. Please wrap your application in <HuskelProvider> instead.");
|
|
94
264
|
clientRef.current = initHuskel(config);
|
|
95
265
|
}
|
|
96
266
|
return clientRef.current;
|
|
97
267
|
}
|
|
98
268
|
|
|
99
269
|
// src/hooks/useSearch.ts
|
|
100
|
-
import { useState, useCallback, useRef as
|
|
270
|
+
import { useState, useCallback, useRef as useRef3 } from "react";
|
|
271
|
+
|
|
272
|
+
// src/components/HuskelProvider.tsx
|
|
273
|
+
import { createContext, useContext, useEffect, useRef as useRef2 } from "react";
|
|
274
|
+
import { jsx } from "react/jsx-runtime";
|
|
275
|
+
var HuskelContext = createContext(null);
|
|
276
|
+
function HuskelProvider({ siteId, apiUrl, apiToken, children }) {
|
|
277
|
+
const clientRef = useRef2(null);
|
|
278
|
+
if (!clientRef.current) {
|
|
279
|
+
clientRef.current = new HuskelClient({ siteId, apiUrl, apiToken });
|
|
280
|
+
}
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
return () => {
|
|
283
|
+
var _a;
|
|
284
|
+
(_a = clientRef.current) == null ? void 0 : _a.destroy();
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
return /* @__PURE__ */ jsx(HuskelContext.Provider, { value: clientRef.current, children });
|
|
288
|
+
}
|
|
289
|
+
function useHuskelContext() {
|
|
290
|
+
const context = useContext(HuskelContext);
|
|
291
|
+
if (!context) {
|
|
292
|
+
return getHuskelClient();
|
|
293
|
+
}
|
|
294
|
+
return context;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/hooks/useSearch.ts
|
|
101
298
|
function useSearch() {
|
|
299
|
+
const client = useHuskelContext();
|
|
102
300
|
const [results, setResults] = useState([]);
|
|
103
301
|
const [loading, setLoading] = useState(false);
|
|
104
302
|
const [error, setError] = useState(null);
|
|
105
|
-
const abortRef =
|
|
303
|
+
const abortRef = useRef3(null);
|
|
106
304
|
const search = useCallback(async (query, limit = 10) => {
|
|
107
305
|
var _a, _b, _c;
|
|
108
306
|
if (!query.trim()) {
|
|
@@ -114,14 +312,14 @@ function useSearch() {
|
|
|
114
312
|
setLoading(true);
|
|
115
313
|
setError(null);
|
|
116
314
|
try {
|
|
117
|
-
const res = await
|
|
315
|
+
const res = await client.api.search(query, limit);
|
|
118
316
|
setResults((_b = res.results) != null ? _b : []);
|
|
119
317
|
} catch (e) {
|
|
120
318
|
setError((_c = e.message) != null ? _c : "Search failed");
|
|
121
319
|
} finally {
|
|
122
320
|
setLoading(false);
|
|
123
321
|
}
|
|
124
|
-
}, []);
|
|
322
|
+
}, [client]);
|
|
125
323
|
const clear = useCallback(() => {
|
|
126
324
|
setResults([]);
|
|
127
325
|
setError(null);
|
|
@@ -132,6 +330,7 @@ function useSearch() {
|
|
|
132
330
|
// src/hooks/useIngest.ts
|
|
133
331
|
import { useCallback as useCallback2, useState as useState2 } from "react";
|
|
134
332
|
function useIngest() {
|
|
333
|
+
const client = useHuskelContext();
|
|
135
334
|
const [loading, setLoading] = useState2(false);
|
|
136
335
|
const [error, setError] = useState2(null);
|
|
137
336
|
const ingest = useCallback2(async (product) => {
|
|
@@ -139,32 +338,32 @@ function useIngest() {
|
|
|
139
338
|
setLoading(true);
|
|
140
339
|
setError(null);
|
|
141
340
|
try {
|
|
142
|
-
await
|
|
341
|
+
await client.queueIngest(product);
|
|
143
342
|
} catch (e) {
|
|
144
343
|
setError((_a = e.message) != null ? _a : "Ingest failed");
|
|
145
344
|
} finally {
|
|
146
345
|
setLoading(false);
|
|
147
346
|
}
|
|
148
|
-
}, []);
|
|
347
|
+
}, [client]);
|
|
149
348
|
const ingestBatch = useCallback2(async (products) => {
|
|
150
349
|
var _a;
|
|
151
350
|
if (!products.length) return;
|
|
152
351
|
setLoading(true);
|
|
153
352
|
setError(null);
|
|
154
353
|
try {
|
|
155
|
-
await
|
|
354
|
+
await client.queueIngestBatch(products);
|
|
156
355
|
} catch (e) {
|
|
157
356
|
setError((_a = e.message) != null ? _a : "Batch ingest failed");
|
|
158
357
|
} finally {
|
|
159
358
|
setLoading(false);
|
|
160
359
|
}
|
|
161
|
-
}, []);
|
|
360
|
+
}, [client]);
|
|
162
361
|
return { ingest, ingestBatch, loading, error };
|
|
163
362
|
}
|
|
164
363
|
|
|
165
364
|
// src/components/SearchBar.tsx
|
|
166
|
-
import { useState as useState3, useEffect as useEffect2, useRef as
|
|
167
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
365
|
+
import { useState as useState3, useEffect as useEffect2, useRef as useRef4 } from "react";
|
|
366
|
+
import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
168
367
|
var S = `
|
|
169
368
|
.hsk-wrap{position:relative;width:100%;font-family:inherit}
|
|
170
369
|
.hsk-input{width:100%;padding:10px 16px;font-size:15px;border:1.5px solid #e2e2e2;border-radius:8px;outline:none;box-sizing:border-box;background:#fff;transition:border-color .2s}
|
|
@@ -190,8 +389,8 @@ function SearchBar({
|
|
|
190
389
|
const [query, setQuery] = useState3("");
|
|
191
390
|
const [open, setOpen] = useState3(false);
|
|
192
391
|
const { results, loading, search, clear } = useSearch();
|
|
193
|
-
const timer =
|
|
194
|
-
const wrap =
|
|
392
|
+
const timer = useRef4();
|
|
393
|
+
const wrap = useRef4(null);
|
|
195
394
|
useEffect2(() => {
|
|
196
395
|
clearTimeout(timer.current);
|
|
197
396
|
if (!query.trim()) {
|
|
@@ -204,7 +403,7 @@ function SearchBar({
|
|
|
204
403
|
setOpen(true);
|
|
205
404
|
}, debounceMs);
|
|
206
405
|
return () => clearTimeout(timer.current);
|
|
207
|
-
}, [query]);
|
|
406
|
+
}, [query, search, clear, limit, debounceMs]);
|
|
208
407
|
useEffect2(() => {
|
|
209
408
|
const handler = (e) => {
|
|
210
409
|
if (wrap.current && !wrap.current.contains(e.target)) setOpen(false);
|
|
@@ -218,9 +417,9 @@ function SearchBar({
|
|
|
218
417
|
onSelect == null ? void 0 : onSelect(r);
|
|
219
418
|
};
|
|
220
419
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
221
|
-
/* @__PURE__ */
|
|
420
|
+
/* @__PURE__ */ jsx2("style", { children: S }),
|
|
222
421
|
/* @__PURE__ */ jsxs("div", { className: `hsk-wrap ${className != null ? className : ""}`, ref: wrap, children: [
|
|
223
|
-
/* @__PURE__ */
|
|
422
|
+
/* @__PURE__ */ jsx2(
|
|
224
423
|
"input",
|
|
225
424
|
{
|
|
226
425
|
className: `hsk-input ${inputClassName != null ? inputClassName : ""}`,
|
|
@@ -232,7 +431,7 @@ function SearchBar({
|
|
|
232
431
|
}
|
|
233
432
|
),
|
|
234
433
|
open && /* @__PURE__ */ jsxs("div", { className: `hsk-drop ${dropdownClassName != null ? dropdownClassName : ""}`, children: [
|
|
235
|
-
loading && /* @__PURE__ */
|
|
434
|
+
loading && /* @__PURE__ */ jsx2("div", { className: "hsk-msg", children: "Searching\u2026" }),
|
|
236
435
|
!loading && results.length === 0 && /* @__PURE__ */ jsxs("div", { className: "hsk-msg", children: [
|
|
237
436
|
'No results for "',
|
|
238
437
|
query,
|
|
@@ -241,10 +440,10 @@ function SearchBar({
|
|
|
241
440
|
results.map(
|
|
242
441
|
(r) => {
|
|
243
442
|
var _a, _b;
|
|
244
|
-
return renderResult ? /* @__PURE__ */
|
|
245
|
-
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */
|
|
443
|
+
return renderResult ? /* @__PURE__ */ jsx2("div", { onClick: () => handleSelect(r), children: renderResult(r) }, r.id) : /* @__PURE__ */ jsxs("div", { className: "hsk-item", onClick: () => handleSelect(r), children: [
|
|
444
|
+
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */ jsx2("img", { src: r.product.images[0], alt: r.product.name }),
|
|
246
445
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
247
|
-
/* @__PURE__ */
|
|
446
|
+
/* @__PURE__ */ jsx2("div", { className: "hsk-item-name", children: r.product.name }),
|
|
248
447
|
/* @__PURE__ */ jsxs("div", { className: "hsk-item-price", children: [
|
|
249
448
|
(_b = r.product.currency) != null ? _b : "KES",
|
|
250
449
|
" ",
|
|
@@ -261,18 +460,19 @@ function SearchBar({
|
|
|
261
460
|
|
|
262
461
|
// src/components/Sparkle.tsx
|
|
263
462
|
import { useState as useState4 } from "react";
|
|
264
|
-
import { Fragment as Fragment2, jsx as
|
|
463
|
+
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
265
464
|
var S2 = `
|
|
266
465
|
.hsk-sparkle{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;font-size:12px;font-weight:600;background:#f47c3c;color:#fff;border:none;border-radius:20px;cursor:pointer;transition:opacity .2s,transform .15s}
|
|
267
466
|
.hsk-sparkle:hover{opacity:.88;transform:scale(1.04)}
|
|
268
467
|
.hsk-sparkle:disabled{opacity:.5;cursor:not-allowed}
|
|
269
468
|
`;
|
|
270
469
|
function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
470
|
+
const client = useHuskelContext();
|
|
271
471
|
const [loading, setLoading] = useState4(false);
|
|
272
472
|
const handleClick = async () => {
|
|
273
473
|
setLoading(true);
|
|
274
474
|
try {
|
|
275
|
-
const res = await
|
|
475
|
+
const res = await client.api.search(productName, limit);
|
|
276
476
|
onResult == null ? void 0 : onResult(res.results);
|
|
277
477
|
} catch (e) {
|
|
278
478
|
console.error("[Huskel Sparkle]", e);
|
|
@@ -281,7 +481,7 @@ function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
|
281
481
|
}
|
|
282
482
|
};
|
|
283
483
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
284
|
-
/* @__PURE__ */
|
|
484
|
+
/* @__PURE__ */ jsx3("style", { children: S2 }),
|
|
285
485
|
/* @__PURE__ */ jsxs2("button", { className: `hsk-sparkle ${className != null ? className : ""}`, onClick: handleClick, disabled: loading, children: [
|
|
286
486
|
"\u2726 ",
|
|
287
487
|
loading ? "Finding\u2026" : "Similar"
|
|
@@ -291,6 +491,7 @@ function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
|
291
491
|
export {
|
|
292
492
|
HuskelAPI,
|
|
293
493
|
HuskelClient,
|
|
494
|
+
HuskelProvider,
|
|
294
495
|
SearchBar,
|
|
295
496
|
Sparkle,
|
|
296
497
|
getHuskelClient,
|
package/package.json
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huskel/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Huskel AI-powered search SDK for SPAs",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
|
|
12
|
-
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
13
11
|
"peerDependencies": {
|
|
14
12
|
"react": ">=17",
|
|
15
13
|
"react-dom": ">=17"
|
|
16
14
|
},
|
|
17
15
|
"peerDependenciesMeta": {
|
|
18
|
-
"react": {
|
|
19
|
-
|
|
16
|
+
"react": {
|
|
17
|
+
"optional": true
|
|
18
|
+
},
|
|
19
|
+
"react-dom": {
|
|
20
|
+
"optional": true
|
|
21
|
+
}
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
24
|
"@types/react": "^18.0.0",
|
|
23
25
|
"tsup": "^8.0.0",
|
|
24
26
|
"typescript": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
30
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
|
|
25
31
|
}
|
|
26
|
-
}
|
|
32
|
+
}
|