@huskel/sdk 0.1.0 → 0.2.1
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 +96 -114
- package/dist/index.d.mts +66 -43
- package/dist/index.d.ts +66 -43
- package/dist/index.js +354 -256
- package/dist/index.mjs +343 -250
- package/package.json +15 -9
package/dist/index.mjs
CHANGED
|
@@ -1,180 +1,239 @@
|
|
|
1
|
-
var __defProp = Object.defineProperty;
|
|
2
|
-
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
3
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
4
|
-
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
5
|
-
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
6
|
-
var __spreadValues = (a, b) => {
|
|
7
|
-
for (var prop in b || (b = {}))
|
|
8
|
-
if (__hasOwnProp.call(b, prop))
|
|
9
|
-
__defNormalProp(a, prop, b[prop]);
|
|
10
|
-
if (__getOwnPropSymbols)
|
|
11
|
-
for (var prop of __getOwnPropSymbols(b)) {
|
|
12
|
-
if (__propIsEnum.call(b, prop))
|
|
13
|
-
__defNormalProp(a, prop, b[prop]);
|
|
14
|
-
}
|
|
15
|
-
return a;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
1
|
// src/api.ts
|
|
2
|
+
var MAX_RETRIES = 3;
|
|
3
|
+
var RETRY_DELAYS = [500, 1e3, 2e3];
|
|
4
|
+
function log(level, msg, data) {
|
|
5
|
+
const prefix = "[Huskel]";
|
|
6
|
+
if (level === "error") console.error(prefix, msg, data != null ? data : "");
|
|
7
|
+
else if (level === "warn") console.warn(prefix, msg, data != null ? data : "");
|
|
8
|
+
else console.log(prefix, msg, data != null ? data : "");
|
|
9
|
+
}
|
|
10
|
+
async function sleep(ms) {
|
|
11
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
}
|
|
19
13
|
var HuskelAPI = class {
|
|
20
|
-
constructor(apiUrl, siteId) {
|
|
14
|
+
constructor(apiUrl, siteId, apiToken) {
|
|
21
15
|
this.apiUrl = apiUrl;
|
|
22
16
|
this.siteId = siteId;
|
|
17
|
+
this.apiToken = apiToken;
|
|
23
18
|
}
|
|
24
|
-
async post(path, body) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
async post(path, body, attempt = 0) {
|
|
20
|
+
const url = `${this.apiUrl}${path}`;
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"X-Huskel-Token": this.apiToken,
|
|
27
|
+
"X-Huskel-Site": this.siteId
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
const err = { status: res.status, message: text };
|
|
34
|
+
if (res.status >= 400 && res.status < 500) {
|
|
35
|
+
log("error", `${path} failed [${res.status}]`, text);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
39
|
+
log("warn", `${path} [${res.status}] retrying (${attempt + 1}/${MAX_RETRIES})...`);
|
|
40
|
+
await sleep(RETRY_DELAYS[attempt]);
|
|
41
|
+
return this.post(path, body, attempt + 1);
|
|
42
|
+
}
|
|
43
|
+
log("error", `${path} failed after ${MAX_RETRIES} attempts`, err);
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return res.json();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (e.status === void 0) {
|
|
49
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
50
|
+
log("warn", `${path} network error, retrying (${attempt + 1}/${MAX_RETRIES})...`);
|
|
51
|
+
await sleep(RETRY_DELAYS[attempt]);
|
|
52
|
+
return this.post(path, body, attempt + 1);
|
|
53
|
+
}
|
|
54
|
+
log("error", `${path} unreachable after ${MAX_RETRIES} attempts`);
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
33
57
|
}
|
|
34
|
-
return res.json();
|
|
35
58
|
}
|
|
36
59
|
async ingest(product) {
|
|
60
|
+
log("info", "ingesting product", product.name);
|
|
37
61
|
return this.post("/ingest", { siteId: this.siteId, product });
|
|
38
62
|
}
|
|
39
63
|
async ingestBatch(products) {
|
|
64
|
+
log("info", `ingesting batch of ${products.length} products`);
|
|
40
65
|
return this.post("/ingest/batch", { siteId: this.siteId, products });
|
|
41
66
|
}
|
|
42
67
|
async search(query, limit = 10) {
|
|
68
|
+
log("info", "search query", query);
|
|
43
69
|
return this.post("/search", { query, siteId: this.siteId, limit });
|
|
44
70
|
}
|
|
45
|
-
async pushConfig(config) {
|
|
46
|
-
return this.post("/sites/config", __spreadValues({ siteId: this.siteId }, config));
|
|
47
|
-
}
|
|
48
71
|
};
|
|
49
72
|
|
|
50
|
-
// src/
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (!selector) return "";
|
|
60
|
-
const found = el.querySelector(selector);
|
|
61
|
-
return (_b = (_a = found == null ? void 0 : found.getAttribute(attr)) == null ? void 0 : _a.trim()) != null ? _b : "";
|
|
62
|
-
}
|
|
63
|
-
function getAll(el, selector) {
|
|
64
|
-
if (!selector) return [];
|
|
65
|
-
return Array.from(el.querySelectorAll(selector)).map((n) => {
|
|
66
|
-
var _a;
|
|
67
|
-
return n.getAttribute("src") || n.getAttribute("href") || ((_a = n.textContent) == null ? void 0 : _a.trim()) || "";
|
|
68
|
-
}).filter(Boolean);
|
|
69
|
-
}
|
|
70
|
-
function parsePrice(raw) {
|
|
71
|
-
const num = parseFloat(raw.replace(/[^0-9.]/g, ""));
|
|
72
|
-
return isNaN(num) ? void 0 : num;
|
|
73
|
-
}
|
|
74
|
-
function extractProducts(config) {
|
|
75
|
-
const containers = document.querySelectorAll(config.selectorContainer);
|
|
76
|
-
if (!containers.length) return [];
|
|
77
|
-
const products = [];
|
|
78
|
-
containers.forEach((el) => {
|
|
79
|
-
var _a;
|
|
80
|
-
const name = getText(el, config.selectorName);
|
|
81
|
-
const price = getText(el, config.selectorPrice);
|
|
82
|
-
const rawUrl = getAttr(el, config.selectorUrl, "href") || el.href || window.location.href;
|
|
83
|
-
const url = rawUrl.startsWith("http") ? rawUrl : `${window.location.origin}${rawUrl}`;
|
|
84
|
-
if (!name || !price || !url) return;
|
|
85
|
-
const product = {
|
|
86
|
-
name,
|
|
87
|
-
price,
|
|
88
|
-
url,
|
|
89
|
-
brand: getText(el, config.selectorBrand) || void 0,
|
|
90
|
-
description: getText(el, config.selectorDescription) || void 0,
|
|
91
|
-
originalPrice: getText(el, config.selectorOriginalPrice) || void 0,
|
|
92
|
-
discount: getText(el, config.selectorDiscount) || void 0,
|
|
93
|
-
currency: (_a = config.currency) != null ? _a : "KES",
|
|
94
|
-
availability: getText(el, config.selectorAvailability) || void 0,
|
|
95
|
-
rating: getText(el, config.selectorRating) || void 0,
|
|
96
|
-
category: getText(el, config.selectorCategory) || void 0,
|
|
97
|
-
images: config.selectorImage ? getAll(el, config.selectorImage) : void 0,
|
|
98
|
-
priceNumeric: parsePrice(price),
|
|
99
|
-
slug: url.split("/").filter(Boolean).pop()
|
|
100
|
-
};
|
|
101
|
-
products.push(product);
|
|
102
|
-
});
|
|
103
|
-
return products;
|
|
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;
|
|
104
82
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
113
97
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const wrap = (original) => function(...args) {
|
|
117
|
-
original.apply(this, args);
|
|
118
|
-
notify();
|
|
119
|
-
};
|
|
120
|
-
history.pushState = wrap(history.pushState);
|
|
121
|
-
history.replaceState = wrap(history.replaceState);
|
|
98
|
+
if (input.priceNumeric !== void 0) {
|
|
99
|
+
priceNumeric = input.priceNumeric;
|
|
122
100
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.current = next;
|
|
127
|
-
this.callbacks.forEach((cb) => cb(next));
|
|
128
|
-
}
|
|
101
|
+
let url = input.url || "";
|
|
102
|
+
if (!url && typeof window !== "undefined") {
|
|
103
|
+
url = window.location.href;
|
|
129
104
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.callbacks = this.callbacks.filter((fn) => fn !== cb);
|
|
134
|
-
};
|
|
105
|
+
let slug = input.slug || input.id || input.productId || "";
|
|
106
|
+
if (!slug && url) {
|
|
107
|
+
slug = url.split("/").filter(Boolean).pop() || "";
|
|
135
108
|
}
|
|
136
|
-
|
|
137
|
-
|
|
109
|
+
if (!slug && name) {
|
|
110
|
+
slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
138
111
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
|
142
154
|
var HuskelClient = class {
|
|
143
155
|
constructor(config) {
|
|
144
|
-
this.
|
|
145
|
-
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
|
+
}
|
|
146
175
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
150
186
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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();
|
|
159
196
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
|
|
169
|
-
}, (_a = this.config.debounceMs) != null ? _a : 600);
|
|
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);
|
|
170
206
|
});
|
|
171
|
-
this.
|
|
207
|
+
if (this.ingestQueue.length > 0) {
|
|
208
|
+
this.scheduleFlush();
|
|
209
|
+
}
|
|
172
210
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
178
237
|
}
|
|
179
238
|
};
|
|
180
239
|
var instance = null;
|
|
@@ -183,19 +242,67 @@ function initHuskel(config) {
|
|
|
183
242
|
return instance;
|
|
184
243
|
}
|
|
185
244
|
function getHuskelClient() {
|
|
186
|
-
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
|
+
}
|
|
187
255
|
return instance;
|
|
188
256
|
}
|
|
189
257
|
|
|
258
|
+
// src/hooks/useHuskel.ts
|
|
259
|
+
import { useRef } from "react";
|
|
260
|
+
function useHuskel(config) {
|
|
261
|
+
const clientRef = useRef(null);
|
|
262
|
+
if (!clientRef.current) {
|
|
263
|
+
console.warn("[Huskel] useHuskel() is deprecated. Please wrap your application in <HuskelProvider> instead.");
|
|
264
|
+
clientRef.current = initHuskel(config);
|
|
265
|
+
}
|
|
266
|
+
return clientRef.current;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/hooks/useSearch.ts
|
|
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
|
+
|
|
190
297
|
// src/hooks/useSearch.ts
|
|
191
|
-
import { useState, useCallback, useRef } from "react";
|
|
192
298
|
function useSearch() {
|
|
299
|
+
const client = useHuskelContext();
|
|
193
300
|
const [results, setResults] = useState([]);
|
|
194
301
|
const [loading, setLoading] = useState(false);
|
|
195
302
|
const [error, setError] = useState(null);
|
|
196
|
-
const abortRef =
|
|
303
|
+
const abortRef = useRef3(null);
|
|
197
304
|
const search = useCallback(async (query, limit = 10) => {
|
|
198
|
-
var _a, _b;
|
|
305
|
+
var _a, _b, _c;
|
|
199
306
|
if (!query.trim()) {
|
|
200
307
|
setResults([]);
|
|
201
308
|
return;
|
|
@@ -205,17 +312,14 @@ function useSearch() {
|
|
|
205
312
|
setLoading(true);
|
|
206
313
|
setError(null);
|
|
207
314
|
try {
|
|
208
|
-
const client = getHuskelClient();
|
|
209
315
|
const res = await client.api.search(query, limit);
|
|
210
316
|
setResults((_b = res.results) != null ? _b : []);
|
|
211
317
|
} catch (e) {
|
|
212
|
-
|
|
213
|
-
setError(e.message);
|
|
214
|
-
}
|
|
318
|
+
setError((_c = e.message) != null ? _c : "Search failed");
|
|
215
319
|
} finally {
|
|
216
320
|
setLoading(false);
|
|
217
321
|
}
|
|
218
|
-
}, []);
|
|
322
|
+
}, [client]);
|
|
219
323
|
const clear = useCallback(() => {
|
|
220
324
|
setResults([]);
|
|
221
325
|
setError(null);
|
|
@@ -223,51 +327,54 @@ function useSearch() {
|
|
|
223
327
|
return { results, loading, error, search, clear };
|
|
224
328
|
}
|
|
225
329
|
|
|
226
|
-
// src/hooks/
|
|
227
|
-
import {
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
|
|
330
|
+
// src/hooks/useIngest.ts
|
|
331
|
+
import { useCallback as useCallback2, useState as useState2 } from "react";
|
|
332
|
+
function useIngest() {
|
|
333
|
+
const client = useHuskelContext();
|
|
334
|
+
const [loading, setLoading] = useState2(false);
|
|
335
|
+
const [error, setError] = useState2(null);
|
|
336
|
+
const ingest = useCallback2(async (product) => {
|
|
231
337
|
var _a;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
client.
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
338
|
+
setLoading(true);
|
|
339
|
+
setError(null);
|
|
340
|
+
try {
|
|
341
|
+
await client.queueIngest(product);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
setError((_a = e.message) != null ? _a : "Ingest failed");
|
|
344
|
+
} finally {
|
|
345
|
+
setLoading(false);
|
|
346
|
+
}
|
|
347
|
+
}, [client]);
|
|
348
|
+
const ingestBatch = useCallback2(async (products) => {
|
|
349
|
+
var _a;
|
|
350
|
+
if (!products.length) return;
|
|
351
|
+
setLoading(true);
|
|
352
|
+
setError(null);
|
|
353
|
+
try {
|
|
354
|
+
await client.queueIngestBatch(products);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
setError((_a = e.message) != null ? _a : "Batch ingest failed");
|
|
357
|
+
} finally {
|
|
358
|
+
setLoading(false);
|
|
359
|
+
}
|
|
360
|
+
}, [client]);
|
|
361
|
+
return { ingest, ingestBatch, loading, error };
|
|
240
362
|
}
|
|
241
363
|
|
|
242
364
|
// src/components/SearchBar.tsx
|
|
243
|
-
import { useState as
|
|
244
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
245
|
-
var
|
|
246
|
-
.
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
.
|
|
254
|
-
.
|
|
255
|
-
|
|
256
|
-
background: #fff; border: 1px solid #e2e2e2; border-radius: 8px;
|
|
257
|
-
box-shadow: 0 8px 24px rgba(0,0,0,0.10); z-index: 9999;
|
|
258
|
-
max-height: 360px; overflow-y: auto;
|
|
259
|
-
}
|
|
260
|
-
.huskel-search-item {
|
|
261
|
-
display: flex; align-items: center; gap: 12px;
|
|
262
|
-
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
|
263
|
-
}
|
|
264
|
-
.huskel-search-item:hover { background: #faf5f1; }
|
|
265
|
-
.huskel-search-item img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; }
|
|
266
|
-
.huskel-search-item-info { flex: 1; min-width: 0; }
|
|
267
|
-
.huskel-search-item-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
268
|
-
.huskel-search-item-price { font-size: 13px; color: #f47c3c; margin-top: 2px; }
|
|
269
|
-
.huskel-search-empty { padding: 16px; text-align: center; color: #888; font-size: 14px; }
|
|
270
|
-
.huskel-search-loading { padding: 16px; text-align: center; color: #aaa; font-size: 13px; }
|
|
365
|
+
import { useState as useState3, useEffect as useEffect2, useRef as useRef4 } from "react";
|
|
366
|
+
import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
367
|
+
var S = `
|
|
368
|
+
.hsk-wrap{position:relative;width:100%;font-family:inherit}
|
|
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}
|
|
370
|
+
.hsk-input:focus{border-color:#f47c3c}
|
|
371
|
+
.hsk-drop{position:absolute;top:calc(100% + 6px);left:0;right:0;background:#fff;border:1px solid #e2e2e2;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.1);z-index:9999;max-height:360px;overflow-y:auto}
|
|
372
|
+
.hsk-item{display:flex;align-items:center;gap:12px;padding:10px 14px;cursor:pointer;transition:background .15s}
|
|
373
|
+
.hsk-item:hover{background:#faf5f1}
|
|
374
|
+
.hsk-item img{width:40px;height:40px;object-fit:cover;border-radius:4px}
|
|
375
|
+
.hsk-item-name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
376
|
+
.hsk-item-price{font-size:13px;color:#f47c3c;margin-top:2px}
|
|
377
|
+
.hsk-msg{padding:16px;text-align:center;font-size:14px;color:#888}
|
|
271
378
|
`;
|
|
272
379
|
function SearchBar({
|
|
273
380
|
placeholder = "Search for what you want \u2014 how you want",
|
|
@@ -275,31 +382,31 @@ function SearchBar({
|
|
|
275
382
|
debounceMs = 300,
|
|
276
383
|
onSelect,
|
|
277
384
|
className,
|
|
385
|
+
inputClassName,
|
|
386
|
+
dropdownClassName,
|
|
278
387
|
renderResult
|
|
279
388
|
}) {
|
|
280
|
-
const [query, setQuery] =
|
|
281
|
-
const [open, setOpen] =
|
|
389
|
+
const [query, setQuery] = useState3("");
|
|
390
|
+
const [open, setOpen] = useState3(false);
|
|
282
391
|
const { results, loading, search, clear } = useSearch();
|
|
283
|
-
const
|
|
284
|
-
const
|
|
392
|
+
const timer = useRef4();
|
|
393
|
+
const wrap = useRef4(null);
|
|
285
394
|
useEffect2(() => {
|
|
286
|
-
clearTimeout(
|
|
395
|
+
clearTimeout(timer.current);
|
|
287
396
|
if (!query.trim()) {
|
|
288
397
|
clear();
|
|
289
398
|
setOpen(false);
|
|
290
399
|
return;
|
|
291
400
|
}
|
|
292
|
-
|
|
401
|
+
timer.current = setTimeout(() => {
|
|
293
402
|
search(query, limit);
|
|
294
403
|
setOpen(true);
|
|
295
404
|
}, debounceMs);
|
|
296
|
-
return () => clearTimeout(
|
|
297
|
-
}, [query]);
|
|
405
|
+
return () => clearTimeout(timer.current);
|
|
406
|
+
}, [query, search, clear, limit, debounceMs]);
|
|
298
407
|
useEffect2(() => {
|
|
299
408
|
const handler = (e) => {
|
|
300
|
-
if (
|
|
301
|
-
setOpen(false);
|
|
302
|
-
}
|
|
409
|
+
if (wrap.current && !wrap.current.contains(e.target)) setOpen(false);
|
|
303
410
|
};
|
|
304
411
|
document.addEventListener("mousedown", handler);
|
|
305
412
|
return () => document.removeEventListener("mousedown", handler);
|
|
@@ -310,12 +417,12 @@ function SearchBar({
|
|
|
310
417
|
onSelect == null ? void 0 : onSelect(r);
|
|
311
418
|
};
|
|
312
419
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
313
|
-
/* @__PURE__ */
|
|
314
|
-
/* @__PURE__ */ jsxs("div", { className: `
|
|
315
|
-
/* @__PURE__ */
|
|
420
|
+
/* @__PURE__ */ jsx2("style", { children: S }),
|
|
421
|
+
/* @__PURE__ */ jsxs("div", { className: `hsk-wrap ${className != null ? className : ""}`, ref: wrap, children: [
|
|
422
|
+
/* @__PURE__ */ jsx2(
|
|
316
423
|
"input",
|
|
317
424
|
{
|
|
318
|
-
className:
|
|
425
|
+
className: `hsk-input ${inputClassName != null ? inputClassName : ""}`,
|
|
319
426
|
type: "text",
|
|
320
427
|
value: query,
|
|
321
428
|
placeholder,
|
|
@@ -323,9 +430,9 @@ function SearchBar({
|
|
|
323
430
|
onFocus: () => results.length && setOpen(true)
|
|
324
431
|
}
|
|
325
432
|
),
|
|
326
|
-
open && /* @__PURE__ */ jsxs("div", { className:
|
|
327
|
-
loading && /* @__PURE__ */
|
|
328
|
-
!loading && results.length === 0 && /* @__PURE__ */ jsxs("div", { className: "
|
|
433
|
+
open && /* @__PURE__ */ jsxs("div", { className: `hsk-drop ${dropdownClassName != null ? dropdownClassName : ""}`, children: [
|
|
434
|
+
loading && /* @__PURE__ */ jsx2("div", { className: "hsk-msg", children: "Searching\u2026" }),
|
|
435
|
+
!loading && results.length === 0 && /* @__PURE__ */ jsxs("div", { className: "hsk-msg", children: [
|
|
329
436
|
'No results for "',
|
|
330
437
|
query,
|
|
331
438
|
'"'
|
|
@@ -333,11 +440,11 @@ function SearchBar({
|
|
|
333
440
|
results.map(
|
|
334
441
|
(r) => {
|
|
335
442
|
var _a, _b;
|
|
336
|
-
return renderResult ? /* @__PURE__ */
|
|
337
|
-
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */
|
|
338
|
-
/* @__PURE__ */ jsxs("div", {
|
|
339
|
-
/* @__PURE__ */
|
|
340
|
-
/* @__PURE__ */ jsxs("div", { className: "
|
|
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 }),
|
|
445
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
446
|
+
/* @__PURE__ */ jsx2("div", { className: "hsk-item-name", children: r.product.name }),
|
|
447
|
+
/* @__PURE__ */ jsxs("div", { className: "hsk-item-price", children: [
|
|
341
448
|
(_b = r.product.currency) != null ? _b : "KES",
|
|
342
449
|
" ",
|
|
343
450
|
r.product.price
|
|
@@ -352,27 +459,20 @@ function SearchBar({
|
|
|
352
459
|
}
|
|
353
460
|
|
|
354
461
|
// src/components/Sparkle.tsx
|
|
355
|
-
import { useState as
|
|
356
|
-
import { Fragment as Fragment2, jsx as
|
|
357
|
-
var
|
|
358
|
-
.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
background: #f47c3c; color: #fff; border: none;
|
|
362
|
-
border-radius: 20px; cursor: pointer; letter-spacing: 0.02em;
|
|
363
|
-
transition: opacity 0.2s, transform 0.15s;
|
|
364
|
-
}
|
|
365
|
-
.huskel-sparkle-btn:hover { opacity: 0.88; transform: scale(1.04); }
|
|
366
|
-
.huskel-sparkle-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
367
|
-
.huskel-sparkle-icon { font-size: 13px; }
|
|
462
|
+
import { useState as useState4 } from "react";
|
|
463
|
+
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
464
|
+
var S2 = `
|
|
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}
|
|
466
|
+
.hsk-sparkle:hover{opacity:.88;transform:scale(1.04)}
|
|
467
|
+
.hsk-sparkle:disabled{opacity:.5;cursor:not-allowed}
|
|
368
468
|
`;
|
|
369
|
-
function Sparkle({ productName,
|
|
370
|
-
const
|
|
469
|
+
function Sparkle({ productName, limit = 5, onResult, className }) {
|
|
470
|
+
const client = useHuskelContext();
|
|
471
|
+
const [loading, setLoading] = useState4(false);
|
|
371
472
|
const handleClick = async () => {
|
|
372
473
|
setLoading(true);
|
|
373
474
|
try {
|
|
374
|
-
const
|
|
375
|
-
const res = await client.api.search(productName, 5);
|
|
475
|
+
const res = await client.api.search(productName, limit);
|
|
376
476
|
onResult == null ? void 0 : onResult(res.results);
|
|
377
477
|
} catch (e) {
|
|
378
478
|
console.error("[Huskel Sparkle]", e);
|
|
@@ -381,29 +481,22 @@ function Sparkle({ productName, className, onResult }) {
|
|
|
381
481
|
}
|
|
382
482
|
};
|
|
383
483
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
384
|
-
/* @__PURE__ */
|
|
385
|
-
/* @__PURE__ */ jsxs2(
|
|
386
|
-
"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
onClick: handleClick,
|
|
390
|
-
disabled: loading,
|
|
391
|
-
title: "Find similar with AI",
|
|
392
|
-
children: [
|
|
393
|
-
/* @__PURE__ */ jsx2("span", { className: "huskel-sparkle-icon", children: "\u2726" }),
|
|
394
|
-
loading ? "Finding\u2026" : "Similar"
|
|
395
|
-
]
|
|
396
|
-
}
|
|
397
|
-
)
|
|
484
|
+
/* @__PURE__ */ jsx3("style", { children: S2 }),
|
|
485
|
+
/* @__PURE__ */ jsxs2("button", { className: `hsk-sparkle ${className != null ? className : ""}`, onClick: handleClick, disabled: loading, children: [
|
|
486
|
+
"\u2726 ",
|
|
487
|
+
loading ? "Finding\u2026" : "Similar"
|
|
488
|
+
] })
|
|
398
489
|
] });
|
|
399
490
|
}
|
|
400
491
|
export {
|
|
401
492
|
HuskelAPI,
|
|
402
493
|
HuskelClient,
|
|
494
|
+
HuskelProvider,
|
|
403
495
|
SearchBar,
|
|
404
496
|
Sparkle,
|
|
405
497
|
getHuskelClient,
|
|
406
498
|
initHuskel,
|
|
407
499
|
useHuskel,
|
|
500
|
+
useIngest,
|
|
408
501
|
useSearch
|
|
409
502
|
};
|