@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/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 res = await fetch(`${this.apiUrl}${path}`, {
26
- method: "POST",
27
- headers: { "Content-Type": "application/json" },
28
- body: JSON.stringify(body)
29
- });
30
- if (!res.ok) {
31
- const err = await res.text();
32
- throw new Error(`Huskel API ${path} [${res.status}]: ${err}`);
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/extractor.ts
51
- function getText(el, selector) {
52
- var _a, _b;
53
- if (!selector) return "";
54
- const found = el.querySelector(selector);
55
- return (_b = (_a = found == null ? void 0 : found.textContent) == null ? void 0 : _a.trim()) != null ? _b : "";
56
- }
57
- function getAttr(el, selector, attr) {
58
- var _a, _b;
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
- // src/observer.ts
107
- var RouteObserver = class {
108
- constructor() {
109
- this.callbacks = [];
110
- this.current = window.location.href;
111
- this.patchHistory();
112
- window.addEventListener("popstate", () => this.notify());
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
- patchHistory() {
115
- const notify = () => this.notify();
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
- notify() {
124
- const next = window.location.href;
125
- if (next !== this.current) {
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
- onChange(cb) {
131
- this.callbacks.push(cb);
132
- return () => {
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
- destroy() {
137
- this.callbacks = [];
109
+ if (!slug && name) {
110
+ slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
138
111
  }
139
- };
140
-
141
- // src/client.ts
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.config = config;
145
- this.api = new HuskelAPI(config.apiUrl, config.siteId);
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
- /** Push selectors config to backend once (idempotent) */
148
- async configure() {
149
- await this.api.pushConfig(this.config.selectors);
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
- /** Scrape current DOM and ingest all products on the page */
152
- async ingestCurrentPage() {
153
- const products = extractProducts(__spreadValues({
154
- siteId: this.config.siteId
155
- }, this.config.selectors));
156
- if (products.length === 0) return [];
157
- await this.api.ingestBatch(products);
158
- return products;
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
- /** Start watching route changes and auto-ingesting */
161
- start() {
162
- if (!this.config.autoIngest) return;
163
- this.observer = new RouteObserver();
164
- this.unsubscribe = this.observer.onChange(() => {
165
- var _a;
166
- clearTimeout(this.debounceTimer);
167
- this.debounceTimer = setTimeout(() => {
168
- this.ingestCurrentPage().catch(console.error);
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.ingestCurrentPage().catch(console.error);
207
+ if (this.ingestQueue.length > 0) {
208
+ this.scheduleFlush();
209
+ }
172
210
  }
173
- stop() {
174
- var _a, _b;
175
- (_a = this.unsubscribe) == null ? void 0 : _a.call(this);
176
- (_b = this.observer) == null ? void 0 : _b.destroy();
177
- clearTimeout(this.debounceTimer);
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) throw new Error("[Huskel] Call initHuskel() before using the client.");
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 = useRef(null);
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
- if (e.name !== "AbortError") {
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/useHuskel.ts
227
- import { useEffect, useRef as useRef2 } from "react";
228
- function useHuskel(options) {
229
- const clientRef = useRef2(null);
230
- useEffect(() => {
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
- const client = initHuskel(options);
233
- clientRef.current = client;
234
- client.configure().then(() => {
235
- client.start();
236
- }).catch((_a = options.onError) != null ? _a : console.error);
237
- return () => client.stop();
238
- }, []);
239
- return clientRef.current;
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 useState2, useEffect as useEffect2, useRef as useRef3 } from "react";
244
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
245
- var DEFAULT_STYLES = `
246
- .huskel-search-wrap { position: relative; width: 100%; font-family: inherit; }
247
- .huskel-search-input {
248
- width: 100%; padding: 10px 16px; font-size: 15px;
249
- border: 1.5px solid #e2e2e2; border-radius: 8px;
250
- outline: none; box-sizing: border-box; background: #fff;
251
- transition: border-color 0.2s;
252
- }
253
- .huskel-search-input:focus { border-color: #f47c3c; }
254
- .huskel-search-dropdown {
255
- position: absolute; top: calc(100% + 6px); left: 0; right: 0;
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] = useState2("");
281
- const [open, setOpen] = useState2(false);
389
+ const [query, setQuery] = useState3("");
390
+ const [open, setOpen] = useState3(false);
282
391
  const { results, loading, search, clear } = useSearch();
283
- const timerRef = useRef3();
284
- const wrapRef = useRef3(null);
392
+ const timer = useRef4();
393
+ const wrap = useRef4(null);
285
394
  useEffect2(() => {
286
- clearTimeout(timerRef.current);
395
+ clearTimeout(timer.current);
287
396
  if (!query.trim()) {
288
397
  clear();
289
398
  setOpen(false);
290
399
  return;
291
400
  }
292
- timerRef.current = setTimeout(() => {
401
+ timer.current = setTimeout(() => {
293
402
  search(query, limit);
294
403
  setOpen(true);
295
404
  }, debounceMs);
296
- return () => clearTimeout(timerRef.current);
297
- }, [query]);
405
+ return () => clearTimeout(timer.current);
406
+ }, [query, search, clear, limit, debounceMs]);
298
407
  useEffect2(() => {
299
408
  const handler = (e) => {
300
- if (wrapRef.current && !wrapRef.current.contains(e.target)) {
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__ */ jsx("style", { children: DEFAULT_STYLES }),
314
- /* @__PURE__ */ jsxs("div", { className: `huskel-search-wrap ${className != null ? className : ""}`, ref: wrapRef, children: [
315
- /* @__PURE__ */ jsx(
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: "huskel-search-input",
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: "huskel-search-dropdown", children: [
327
- loading && /* @__PURE__ */ jsx("div", { className: "huskel-search-loading", children: "Searching\u2026" }),
328
- !loading && results.length === 0 && /* @__PURE__ */ jsxs("div", { className: "huskel-search-empty", children: [
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__ */ jsx("div", { onClick: () => handleSelect(r), children: renderResult(r) }, r.id) : /* @__PURE__ */ jsxs("div", { className: "huskel-search-item", onClick: () => handleSelect(r), children: [
337
- ((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */ jsx("img", { src: r.product.images[0], alt: r.product.name }),
338
- /* @__PURE__ */ jsxs("div", { className: "huskel-search-item-info", children: [
339
- /* @__PURE__ */ jsx("div", { className: "huskel-search-item-name", children: r.product.name }),
340
- /* @__PURE__ */ jsxs("div", { className: "huskel-search-item-price", children: [
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 useState3 } from "react";
356
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
357
- var STYLES = `
358
- .huskel-sparkle-btn {
359
- display: inline-flex; align-items: center; gap: 5px;
360
- padding: 4px 10px; font-size: 12px; font-weight: 600;
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, className, onResult }) {
370
- const [loading, setLoading] = useState3(false);
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 client = getHuskelClient();
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__ */ jsx2("style", { children: STYLES }),
385
- /* @__PURE__ */ jsxs2(
386
- "button",
387
- {
388
- className: `huskel-sparkle-btn ${className != null ? className : ""}`,
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
  };