@huskel/sdk 0.1.0
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 +114 -0
- package/dist/index.d.mts +132 -0
- package/dist/index.d.ts +132 -0
- package/dist/index.js +440 -0
- package/dist/index.mjs +409 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @huskel/sdk
|
|
2
|
+
|
|
3
|
+
AI-powered search SDK for SPAs. Drop in, point at your product DOM, get vector search + LLM responses.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @huskel/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup (Next.js app layout)
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
// app/layout.tsx or _app.tsx
|
|
15
|
+
'use client';
|
|
16
|
+
import { useHuskel } from '@huskel/sdk';
|
|
17
|
+
|
|
18
|
+
export default function RootLayout({ children }) {
|
|
19
|
+
useHuskel({
|
|
20
|
+
siteId: 'your-site-id',
|
|
21
|
+
apiUrl: 'https://your-huskel-backend.com',
|
|
22
|
+
autoIngest: true,
|
|
23
|
+
debounceMs: 600,
|
|
24
|
+
selectors: {
|
|
25
|
+
selectorContainer: '.product-card',
|
|
26
|
+
selectorName: '.product-title',
|
|
27
|
+
selectorPrice: '.product-price',
|
|
28
|
+
selectorImage: '.product-image img',
|
|
29
|
+
selectorUrl: 'a.product-link',
|
|
30
|
+
selectorDescription: '.product-desc',
|
|
31
|
+
selectorCategory: '.product-category',
|
|
32
|
+
currency: 'KES',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return <html><body>{children}</body></html>;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Search bar
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { SearchBar } from '@huskel/sdk';
|
|
44
|
+
|
|
45
|
+
export function Header() {
|
|
46
|
+
return (
|
|
47
|
+
<SearchBar
|
|
48
|
+
placeholder="Search for what you want — how you want"
|
|
49
|
+
onSelect={(result) => {
|
|
50
|
+
window.location.href = result.product.url;
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Sparkle on product card
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { Sparkle } from '@huskel/sdk';
|
|
61
|
+
|
|
62
|
+
export function ProductCard({ product }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="product-card">
|
|
65
|
+
<img src={product.image} />
|
|
66
|
+
<h3>{product.name}</h3>
|
|
67
|
+
<p>{product.price}</p>
|
|
68
|
+
<Sparkle
|
|
69
|
+
productName={product.name}
|
|
70
|
+
onResult={(similar) => console.log(similar)}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## useSearch hook (custom UI)
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { useSearch } from '@huskel/sdk';
|
|
81
|
+
|
|
82
|
+
export function CustomSearch() {
|
|
83
|
+
const { results, loading, search } = useSearch();
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div>
|
|
87
|
+
<input onChange={e => search(e.target.value)} />
|
|
88
|
+
{loading && <p>Loading…</p>}
|
|
89
|
+
{results.map(r => <div key={r.id}>{r.product.name}</div>)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Manual ingest
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import { getHuskelClient } from '@huskel/sdk';
|
|
99
|
+
|
|
100
|
+
const client = getHuskelClient();
|
|
101
|
+
|
|
102
|
+
// Single product
|
|
103
|
+
await client.api.ingest({ name: 'Maize Flour 2kg', price: 'KES 180', url: '/products/maize-flour' });
|
|
104
|
+
|
|
105
|
+
// Batch
|
|
106
|
+
await client.api.ingestBatch([...products]);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
1. `useHuskel` pushes your CSS selector config to the backend on mount.
|
|
112
|
+
2. On every SPA route change, the SDK waits for the DOM to settle (debounce), then extracts products using your selectors.
|
|
113
|
+
3. Products are batch-ingested to your Go backend → Upstash (BAAI/BGE embeddings) → NeonDB.
|
|
114
|
+
4. `useSearch` / `SearchBar` hit `/search` for vector similarity results powered by the ingested data.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
interface Product {
|
|
5
|
+
name: string;
|
|
6
|
+
price: string;
|
|
7
|
+
url: string;
|
|
8
|
+
brand?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
originalPrice?: string;
|
|
11
|
+
discount?: string;
|
|
12
|
+
currency?: string;
|
|
13
|
+
stock?: string;
|
|
14
|
+
availability?: string;
|
|
15
|
+
rating?: string;
|
|
16
|
+
reviewCount?: number;
|
|
17
|
+
category?: string;
|
|
18
|
+
subCategory?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
images?: string[];
|
|
21
|
+
specs?: Record<string, string>;
|
|
22
|
+
priceNumeric?: number;
|
|
23
|
+
slug?: string;
|
|
24
|
+
}
|
|
25
|
+
interface SiteConfig {
|
|
26
|
+
siteId: string;
|
|
27
|
+
selectorContainer: string;
|
|
28
|
+
selectorName: string;
|
|
29
|
+
selectorPrice: string;
|
|
30
|
+
selectorImage?: string;
|
|
31
|
+
selectorUrl?: string;
|
|
32
|
+
selectorDescription?: string;
|
|
33
|
+
selectorCategory?: string;
|
|
34
|
+
selectorBrand?: string;
|
|
35
|
+
selectorAvailability?: string;
|
|
36
|
+
selectorSku?: string;
|
|
37
|
+
selectorOriginalPrice?: string;
|
|
38
|
+
selectorDiscount?: string;
|
|
39
|
+
selectorRating?: string;
|
|
40
|
+
selectorNextPage?: string;
|
|
41
|
+
currency?: string;
|
|
42
|
+
maxPages?: number;
|
|
43
|
+
}
|
|
44
|
+
interface HuskelConfig {
|
|
45
|
+
siteId: string;
|
|
46
|
+
apiUrl: string;
|
|
47
|
+
selectors: Omit<SiteConfig, 'siteId'>;
|
|
48
|
+
autoIngest?: boolean;
|
|
49
|
+
debounceMs?: number;
|
|
50
|
+
}
|
|
51
|
+
interface SearchRequest {
|
|
52
|
+
query: string;
|
|
53
|
+
siteId: string;
|
|
54
|
+
limit?: number;
|
|
55
|
+
}
|
|
56
|
+
interface SearchResult {
|
|
57
|
+
id: string;
|
|
58
|
+
score: number;
|
|
59
|
+
product: Product;
|
|
60
|
+
}
|
|
61
|
+
interface SearchResponse {
|
|
62
|
+
results: SearchResult[];
|
|
63
|
+
query: string;
|
|
64
|
+
}
|
|
65
|
+
interface IngestResponse {
|
|
66
|
+
success: boolean;
|
|
67
|
+
message?: string;
|
|
68
|
+
count?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
declare class HuskelAPI {
|
|
72
|
+
private apiUrl;
|
|
73
|
+
private siteId;
|
|
74
|
+
constructor(apiUrl: string, siteId: string);
|
|
75
|
+
private post;
|
|
76
|
+
ingest(product: Product): Promise<IngestResponse>;
|
|
77
|
+
ingestBatch(products: Product[]): Promise<IngestResponse>;
|
|
78
|
+
search(query: string, limit?: number): Promise<SearchResponse>;
|
|
79
|
+
pushConfig(config: Omit<SiteConfig, 'siteId'>): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare class HuskelClient {
|
|
83
|
+
private config;
|
|
84
|
+
readonly api: HuskelAPI;
|
|
85
|
+
private observer?;
|
|
86
|
+
private unsubscribe?;
|
|
87
|
+
private debounceTimer?;
|
|
88
|
+
constructor(config: HuskelConfig);
|
|
89
|
+
/** Push selectors config to backend once (idempotent) */
|
|
90
|
+
configure(): Promise<void>;
|
|
91
|
+
/** Scrape current DOM and ingest all products on the page */
|
|
92
|
+
ingestCurrentPage(): Promise<Product[]>;
|
|
93
|
+
/** Start watching route changes and auto-ingesting */
|
|
94
|
+
start(): void;
|
|
95
|
+
stop(): void;
|
|
96
|
+
}
|
|
97
|
+
declare function initHuskel(config: HuskelConfig): HuskelClient;
|
|
98
|
+
declare function getHuskelClient(): HuskelClient;
|
|
99
|
+
|
|
100
|
+
interface UseSearchReturn {
|
|
101
|
+
results: SearchResult[];
|
|
102
|
+
loading: boolean;
|
|
103
|
+
error: string | null;
|
|
104
|
+
search: (query: string, limit?: number) => Promise<void>;
|
|
105
|
+
clear: () => void;
|
|
106
|
+
}
|
|
107
|
+
declare function useSearch(): UseSearchReturn;
|
|
108
|
+
|
|
109
|
+
interface UseHuskelOptions extends HuskelConfig {
|
|
110
|
+
onIngest?: (products: Product[]) => void;
|
|
111
|
+
onError?: (err: Error) => void;
|
|
112
|
+
}
|
|
113
|
+
declare function useHuskel(options: UseHuskelOptions): HuskelClient | null;
|
|
114
|
+
|
|
115
|
+
interface SearchBarProps {
|
|
116
|
+
placeholder?: string;
|
|
117
|
+
limit?: number;
|
|
118
|
+
debounceMs?: number;
|
|
119
|
+
onSelect?: (result: SearchResult) => void;
|
|
120
|
+
className?: string;
|
|
121
|
+
renderResult?: (result: SearchResult) => React.ReactNode;
|
|
122
|
+
}
|
|
123
|
+
declare function SearchBar({ placeholder, limit, debounceMs, onSelect, className, renderResult, }: SearchBarProps): react_jsx_runtime.JSX.Element;
|
|
124
|
+
|
|
125
|
+
interface SparkleProps {
|
|
126
|
+
productName: string;
|
|
127
|
+
className?: string;
|
|
128
|
+
onResult?: (results: SearchResult[]) => void;
|
|
129
|
+
}
|
|
130
|
+
declare function Sparkle({ productName, className, onResult }: SparkleProps): react_jsx_runtime.JSX.Element;
|
|
131
|
+
|
|
132
|
+
export { HuskelAPI, HuskelClient, type HuskelConfig, type IngestResponse, type Product, SearchBar, type SearchRequest, type SearchResponse, type SearchResult, type SiteConfig, Sparkle, getHuskelClient, initHuskel, useHuskel, useSearch };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
interface Product {
|
|
5
|
+
name: string;
|
|
6
|
+
price: string;
|
|
7
|
+
url: string;
|
|
8
|
+
brand?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
originalPrice?: string;
|
|
11
|
+
discount?: string;
|
|
12
|
+
currency?: string;
|
|
13
|
+
stock?: string;
|
|
14
|
+
availability?: string;
|
|
15
|
+
rating?: string;
|
|
16
|
+
reviewCount?: number;
|
|
17
|
+
category?: string;
|
|
18
|
+
subCategory?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
images?: string[];
|
|
21
|
+
specs?: Record<string, string>;
|
|
22
|
+
priceNumeric?: number;
|
|
23
|
+
slug?: string;
|
|
24
|
+
}
|
|
25
|
+
interface SiteConfig {
|
|
26
|
+
siteId: string;
|
|
27
|
+
selectorContainer: string;
|
|
28
|
+
selectorName: string;
|
|
29
|
+
selectorPrice: string;
|
|
30
|
+
selectorImage?: string;
|
|
31
|
+
selectorUrl?: string;
|
|
32
|
+
selectorDescription?: string;
|
|
33
|
+
selectorCategory?: string;
|
|
34
|
+
selectorBrand?: string;
|
|
35
|
+
selectorAvailability?: string;
|
|
36
|
+
selectorSku?: string;
|
|
37
|
+
selectorOriginalPrice?: string;
|
|
38
|
+
selectorDiscount?: string;
|
|
39
|
+
selectorRating?: string;
|
|
40
|
+
selectorNextPage?: string;
|
|
41
|
+
currency?: string;
|
|
42
|
+
maxPages?: number;
|
|
43
|
+
}
|
|
44
|
+
interface HuskelConfig {
|
|
45
|
+
siteId: string;
|
|
46
|
+
apiUrl: string;
|
|
47
|
+
selectors: Omit<SiteConfig, 'siteId'>;
|
|
48
|
+
autoIngest?: boolean;
|
|
49
|
+
debounceMs?: number;
|
|
50
|
+
}
|
|
51
|
+
interface SearchRequest {
|
|
52
|
+
query: string;
|
|
53
|
+
siteId: string;
|
|
54
|
+
limit?: number;
|
|
55
|
+
}
|
|
56
|
+
interface SearchResult {
|
|
57
|
+
id: string;
|
|
58
|
+
score: number;
|
|
59
|
+
product: Product;
|
|
60
|
+
}
|
|
61
|
+
interface SearchResponse {
|
|
62
|
+
results: SearchResult[];
|
|
63
|
+
query: string;
|
|
64
|
+
}
|
|
65
|
+
interface IngestResponse {
|
|
66
|
+
success: boolean;
|
|
67
|
+
message?: string;
|
|
68
|
+
count?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
declare class HuskelAPI {
|
|
72
|
+
private apiUrl;
|
|
73
|
+
private siteId;
|
|
74
|
+
constructor(apiUrl: string, siteId: string);
|
|
75
|
+
private post;
|
|
76
|
+
ingest(product: Product): Promise<IngestResponse>;
|
|
77
|
+
ingestBatch(products: Product[]): Promise<IngestResponse>;
|
|
78
|
+
search(query: string, limit?: number): Promise<SearchResponse>;
|
|
79
|
+
pushConfig(config: Omit<SiteConfig, 'siteId'>): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare class HuskelClient {
|
|
83
|
+
private config;
|
|
84
|
+
readonly api: HuskelAPI;
|
|
85
|
+
private observer?;
|
|
86
|
+
private unsubscribe?;
|
|
87
|
+
private debounceTimer?;
|
|
88
|
+
constructor(config: HuskelConfig);
|
|
89
|
+
/** Push selectors config to backend once (idempotent) */
|
|
90
|
+
configure(): Promise<void>;
|
|
91
|
+
/** Scrape current DOM and ingest all products on the page */
|
|
92
|
+
ingestCurrentPage(): Promise<Product[]>;
|
|
93
|
+
/** Start watching route changes and auto-ingesting */
|
|
94
|
+
start(): void;
|
|
95
|
+
stop(): void;
|
|
96
|
+
}
|
|
97
|
+
declare function initHuskel(config: HuskelConfig): HuskelClient;
|
|
98
|
+
declare function getHuskelClient(): HuskelClient;
|
|
99
|
+
|
|
100
|
+
interface UseSearchReturn {
|
|
101
|
+
results: SearchResult[];
|
|
102
|
+
loading: boolean;
|
|
103
|
+
error: string | null;
|
|
104
|
+
search: (query: string, limit?: number) => Promise<void>;
|
|
105
|
+
clear: () => void;
|
|
106
|
+
}
|
|
107
|
+
declare function useSearch(): UseSearchReturn;
|
|
108
|
+
|
|
109
|
+
interface UseHuskelOptions extends HuskelConfig {
|
|
110
|
+
onIngest?: (products: Product[]) => void;
|
|
111
|
+
onError?: (err: Error) => void;
|
|
112
|
+
}
|
|
113
|
+
declare function useHuskel(options: UseHuskelOptions): HuskelClient | null;
|
|
114
|
+
|
|
115
|
+
interface SearchBarProps {
|
|
116
|
+
placeholder?: string;
|
|
117
|
+
limit?: number;
|
|
118
|
+
debounceMs?: number;
|
|
119
|
+
onSelect?: (result: SearchResult) => void;
|
|
120
|
+
className?: string;
|
|
121
|
+
renderResult?: (result: SearchResult) => React.ReactNode;
|
|
122
|
+
}
|
|
123
|
+
declare function SearchBar({ placeholder, limit, debounceMs, onSelect, className, renderResult, }: SearchBarProps): react_jsx_runtime.JSX.Element;
|
|
124
|
+
|
|
125
|
+
interface SparkleProps {
|
|
126
|
+
productName: string;
|
|
127
|
+
className?: string;
|
|
128
|
+
onResult?: (results: SearchResult[]) => void;
|
|
129
|
+
}
|
|
130
|
+
declare function Sparkle({ productName, className, onResult }: SparkleProps): react_jsx_runtime.JSX.Element;
|
|
131
|
+
|
|
132
|
+
export { HuskelAPI, HuskelClient, type HuskelConfig, type IngestResponse, type Product, SearchBar, type SearchRequest, type SearchResponse, type SearchResult, type SiteConfig, Sparkle, getHuskelClient, initHuskel, useHuskel, useSearch };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
8
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
9
|
+
var __spreadValues = (a, b) => {
|
|
10
|
+
for (var prop in b || (b = {}))
|
|
11
|
+
if (__hasOwnProp.call(b, prop))
|
|
12
|
+
__defNormalProp(a, prop, b[prop]);
|
|
13
|
+
if (__getOwnPropSymbols)
|
|
14
|
+
for (var prop of __getOwnPropSymbols(b)) {
|
|
15
|
+
if (__propIsEnum.call(b, prop))
|
|
16
|
+
__defNormalProp(a, prop, b[prop]);
|
|
17
|
+
}
|
|
18
|
+
return a;
|
|
19
|
+
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
23
|
+
};
|
|
24
|
+
var __copyProps = (to, from, except, desc) => {
|
|
25
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
26
|
+
for (let key of __getOwnPropNames(from))
|
|
27
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
28
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
29
|
+
}
|
|
30
|
+
return to;
|
|
31
|
+
};
|
|
32
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
33
|
+
|
|
34
|
+
// src/index.ts
|
|
35
|
+
var index_exports = {};
|
|
36
|
+
__export(index_exports, {
|
|
37
|
+
HuskelAPI: () => HuskelAPI,
|
|
38
|
+
HuskelClient: () => HuskelClient,
|
|
39
|
+
SearchBar: () => SearchBar,
|
|
40
|
+
Sparkle: () => Sparkle,
|
|
41
|
+
getHuskelClient: () => getHuskelClient,
|
|
42
|
+
initHuskel: () => initHuskel,
|
|
43
|
+
useHuskel: () => useHuskel,
|
|
44
|
+
useSearch: () => useSearch
|
|
45
|
+
});
|
|
46
|
+
module.exports = __toCommonJS(index_exports);
|
|
47
|
+
|
|
48
|
+
// src/api.ts
|
|
49
|
+
var HuskelAPI = class {
|
|
50
|
+
constructor(apiUrl, siteId) {
|
|
51
|
+
this.apiUrl = apiUrl;
|
|
52
|
+
this.siteId = siteId;
|
|
53
|
+
}
|
|
54
|
+
async post(path, body) {
|
|
55
|
+
const res = await fetch(`${this.apiUrl}${path}`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify(body)
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const err = await res.text();
|
|
62
|
+
throw new Error(`Huskel API ${path} [${res.status}]: ${err}`);
|
|
63
|
+
}
|
|
64
|
+
return res.json();
|
|
65
|
+
}
|
|
66
|
+
async ingest(product) {
|
|
67
|
+
return this.post("/ingest", { siteId: this.siteId, product });
|
|
68
|
+
}
|
|
69
|
+
async ingestBatch(products) {
|
|
70
|
+
return this.post("/ingest/batch", { siteId: this.siteId, products });
|
|
71
|
+
}
|
|
72
|
+
async search(query, limit = 10) {
|
|
73
|
+
return this.post("/search", { query, siteId: this.siteId, limit });
|
|
74
|
+
}
|
|
75
|
+
async pushConfig(config) {
|
|
76
|
+
return this.post("/sites/config", __spreadValues({ siteId: this.siteId }, config));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// src/extractor.ts
|
|
81
|
+
function getText(el, selector) {
|
|
82
|
+
var _a, _b;
|
|
83
|
+
if (!selector) return "";
|
|
84
|
+
const found = el.querySelector(selector);
|
|
85
|
+
return (_b = (_a = found == null ? void 0 : found.textContent) == null ? void 0 : _a.trim()) != null ? _b : "";
|
|
86
|
+
}
|
|
87
|
+
function getAttr(el, selector, attr) {
|
|
88
|
+
var _a, _b;
|
|
89
|
+
if (!selector) return "";
|
|
90
|
+
const found = el.querySelector(selector);
|
|
91
|
+
return (_b = (_a = found == null ? void 0 : found.getAttribute(attr)) == null ? void 0 : _a.trim()) != null ? _b : "";
|
|
92
|
+
}
|
|
93
|
+
function getAll(el, selector) {
|
|
94
|
+
if (!selector) return [];
|
|
95
|
+
return Array.from(el.querySelectorAll(selector)).map((n) => {
|
|
96
|
+
var _a;
|
|
97
|
+
return n.getAttribute("src") || n.getAttribute("href") || ((_a = n.textContent) == null ? void 0 : _a.trim()) || "";
|
|
98
|
+
}).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
function parsePrice(raw) {
|
|
101
|
+
const num = parseFloat(raw.replace(/[^0-9.]/g, ""));
|
|
102
|
+
return isNaN(num) ? void 0 : num;
|
|
103
|
+
}
|
|
104
|
+
function extractProducts(config) {
|
|
105
|
+
const containers = document.querySelectorAll(config.selectorContainer);
|
|
106
|
+
if (!containers.length) return [];
|
|
107
|
+
const products = [];
|
|
108
|
+
containers.forEach((el) => {
|
|
109
|
+
var _a;
|
|
110
|
+
const name = getText(el, config.selectorName);
|
|
111
|
+
const price = getText(el, config.selectorPrice);
|
|
112
|
+
const rawUrl = getAttr(el, config.selectorUrl, "href") || el.href || window.location.href;
|
|
113
|
+
const url = rawUrl.startsWith("http") ? rawUrl : `${window.location.origin}${rawUrl}`;
|
|
114
|
+
if (!name || !price || !url) return;
|
|
115
|
+
const product = {
|
|
116
|
+
name,
|
|
117
|
+
price,
|
|
118
|
+
url,
|
|
119
|
+
brand: getText(el, config.selectorBrand) || void 0,
|
|
120
|
+
description: getText(el, config.selectorDescription) || void 0,
|
|
121
|
+
originalPrice: getText(el, config.selectorOriginalPrice) || void 0,
|
|
122
|
+
discount: getText(el, config.selectorDiscount) || void 0,
|
|
123
|
+
currency: (_a = config.currency) != null ? _a : "KES",
|
|
124
|
+
availability: getText(el, config.selectorAvailability) || void 0,
|
|
125
|
+
rating: getText(el, config.selectorRating) || void 0,
|
|
126
|
+
category: getText(el, config.selectorCategory) || void 0,
|
|
127
|
+
images: config.selectorImage ? getAll(el, config.selectorImage) : void 0,
|
|
128
|
+
priceNumeric: parsePrice(price),
|
|
129
|
+
slug: url.split("/").filter(Boolean).pop()
|
|
130
|
+
};
|
|
131
|
+
products.push(product);
|
|
132
|
+
});
|
|
133
|
+
return products;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/observer.ts
|
|
137
|
+
var RouteObserver = class {
|
|
138
|
+
constructor() {
|
|
139
|
+
this.callbacks = [];
|
|
140
|
+
this.current = window.location.href;
|
|
141
|
+
this.patchHistory();
|
|
142
|
+
window.addEventListener("popstate", () => this.notify());
|
|
143
|
+
}
|
|
144
|
+
patchHistory() {
|
|
145
|
+
const notify = () => this.notify();
|
|
146
|
+
const wrap = (original) => function(...args) {
|
|
147
|
+
original.apply(this, args);
|
|
148
|
+
notify();
|
|
149
|
+
};
|
|
150
|
+
history.pushState = wrap(history.pushState);
|
|
151
|
+
history.replaceState = wrap(history.replaceState);
|
|
152
|
+
}
|
|
153
|
+
notify() {
|
|
154
|
+
const next = window.location.href;
|
|
155
|
+
if (next !== this.current) {
|
|
156
|
+
this.current = next;
|
|
157
|
+
this.callbacks.forEach((cb) => cb(next));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
onChange(cb) {
|
|
161
|
+
this.callbacks.push(cb);
|
|
162
|
+
return () => {
|
|
163
|
+
this.callbacks = this.callbacks.filter((fn) => fn !== cb);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
destroy() {
|
|
167
|
+
this.callbacks = [];
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// src/client.ts
|
|
172
|
+
var HuskelClient = class {
|
|
173
|
+
constructor(config) {
|
|
174
|
+
this.config = config;
|
|
175
|
+
this.api = new HuskelAPI(config.apiUrl, config.siteId);
|
|
176
|
+
}
|
|
177
|
+
/** Push selectors config to backend once (idempotent) */
|
|
178
|
+
async configure() {
|
|
179
|
+
await this.api.pushConfig(this.config.selectors);
|
|
180
|
+
}
|
|
181
|
+
/** Scrape current DOM and ingest all products on the page */
|
|
182
|
+
async ingestCurrentPage() {
|
|
183
|
+
const products = extractProducts(__spreadValues({
|
|
184
|
+
siteId: this.config.siteId
|
|
185
|
+
}, this.config.selectors));
|
|
186
|
+
if (products.length === 0) return [];
|
|
187
|
+
await this.api.ingestBatch(products);
|
|
188
|
+
return products;
|
|
189
|
+
}
|
|
190
|
+
/** Start watching route changes and auto-ingesting */
|
|
191
|
+
start() {
|
|
192
|
+
if (!this.config.autoIngest) return;
|
|
193
|
+
this.observer = new RouteObserver();
|
|
194
|
+
this.unsubscribe = this.observer.onChange(() => {
|
|
195
|
+
var _a;
|
|
196
|
+
clearTimeout(this.debounceTimer);
|
|
197
|
+
this.debounceTimer = setTimeout(() => {
|
|
198
|
+
this.ingestCurrentPage().catch(console.error);
|
|
199
|
+
}, (_a = this.config.debounceMs) != null ? _a : 600);
|
|
200
|
+
});
|
|
201
|
+
this.ingestCurrentPage().catch(console.error);
|
|
202
|
+
}
|
|
203
|
+
stop() {
|
|
204
|
+
var _a, _b;
|
|
205
|
+
(_a = this.unsubscribe) == null ? void 0 : _a.call(this);
|
|
206
|
+
(_b = this.observer) == null ? void 0 : _b.destroy();
|
|
207
|
+
clearTimeout(this.debounceTimer);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
var instance = null;
|
|
211
|
+
function initHuskel(config) {
|
|
212
|
+
instance = new HuskelClient(config);
|
|
213
|
+
return instance;
|
|
214
|
+
}
|
|
215
|
+
function getHuskelClient() {
|
|
216
|
+
if (!instance) throw new Error("[Huskel] Call initHuskel() before using the client.");
|
|
217
|
+
return instance;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/hooks/useSearch.ts
|
|
221
|
+
var import_react = require("react");
|
|
222
|
+
function useSearch() {
|
|
223
|
+
const [results, setResults] = (0, import_react.useState)([]);
|
|
224
|
+
const [loading, setLoading] = (0, import_react.useState)(false);
|
|
225
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
226
|
+
const abortRef = (0, import_react.useRef)(null);
|
|
227
|
+
const search = (0, import_react.useCallback)(async (query, limit = 10) => {
|
|
228
|
+
var _a, _b;
|
|
229
|
+
if (!query.trim()) {
|
|
230
|
+
setResults([]);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
(_a = abortRef.current) == null ? void 0 : _a.abort();
|
|
234
|
+
abortRef.current = new AbortController();
|
|
235
|
+
setLoading(true);
|
|
236
|
+
setError(null);
|
|
237
|
+
try {
|
|
238
|
+
const client = getHuskelClient();
|
|
239
|
+
const res = await client.api.search(query, limit);
|
|
240
|
+
setResults((_b = res.results) != null ? _b : []);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (e.name !== "AbortError") {
|
|
243
|
+
setError(e.message);
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
setLoading(false);
|
|
247
|
+
}
|
|
248
|
+
}, []);
|
|
249
|
+
const clear = (0, import_react.useCallback)(() => {
|
|
250
|
+
setResults([]);
|
|
251
|
+
setError(null);
|
|
252
|
+
}, []);
|
|
253
|
+
return { results, loading, error, search, clear };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/hooks/useHuskel.ts
|
|
257
|
+
var import_react2 = require("react");
|
|
258
|
+
function useHuskel(options) {
|
|
259
|
+
const clientRef = (0, import_react2.useRef)(null);
|
|
260
|
+
(0, import_react2.useEffect)(() => {
|
|
261
|
+
var _a;
|
|
262
|
+
const client = initHuskel(options);
|
|
263
|
+
clientRef.current = client;
|
|
264
|
+
client.configure().then(() => {
|
|
265
|
+
client.start();
|
|
266
|
+
}).catch((_a = options.onError) != null ? _a : console.error);
|
|
267
|
+
return () => client.stop();
|
|
268
|
+
}, []);
|
|
269
|
+
return clientRef.current;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/components/SearchBar.tsx
|
|
273
|
+
var import_react3 = require("react");
|
|
274
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
275
|
+
var DEFAULT_STYLES = `
|
|
276
|
+
.huskel-search-wrap { position: relative; width: 100%; font-family: inherit; }
|
|
277
|
+
.huskel-search-input {
|
|
278
|
+
width: 100%; padding: 10px 16px; font-size: 15px;
|
|
279
|
+
border: 1.5px solid #e2e2e2; border-radius: 8px;
|
|
280
|
+
outline: none; box-sizing: border-box; background: #fff;
|
|
281
|
+
transition: border-color 0.2s;
|
|
282
|
+
}
|
|
283
|
+
.huskel-search-input:focus { border-color: #f47c3c; }
|
|
284
|
+
.huskel-search-dropdown {
|
|
285
|
+
position: absolute; top: calc(100% + 6px); left: 0; right: 0;
|
|
286
|
+
background: #fff; border: 1px solid #e2e2e2; border-radius: 8px;
|
|
287
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.10); z-index: 9999;
|
|
288
|
+
max-height: 360px; overflow-y: auto;
|
|
289
|
+
}
|
|
290
|
+
.huskel-search-item {
|
|
291
|
+
display: flex; align-items: center; gap: 12px;
|
|
292
|
+
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
|
293
|
+
}
|
|
294
|
+
.huskel-search-item:hover { background: #faf5f1; }
|
|
295
|
+
.huskel-search-item img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; }
|
|
296
|
+
.huskel-search-item-info { flex: 1; min-width: 0; }
|
|
297
|
+
.huskel-search-item-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
298
|
+
.huskel-search-item-price { font-size: 13px; color: #f47c3c; margin-top: 2px; }
|
|
299
|
+
.huskel-search-empty { padding: 16px; text-align: center; color: #888; font-size: 14px; }
|
|
300
|
+
.huskel-search-loading { padding: 16px; text-align: center; color: #aaa; font-size: 13px; }
|
|
301
|
+
`;
|
|
302
|
+
function SearchBar({
|
|
303
|
+
placeholder = "Search for what you want \u2014 how you want",
|
|
304
|
+
limit = 10,
|
|
305
|
+
debounceMs = 300,
|
|
306
|
+
onSelect,
|
|
307
|
+
className,
|
|
308
|
+
renderResult
|
|
309
|
+
}) {
|
|
310
|
+
const [query, setQuery] = (0, import_react3.useState)("");
|
|
311
|
+
const [open, setOpen] = (0, import_react3.useState)(false);
|
|
312
|
+
const { results, loading, search, clear } = useSearch();
|
|
313
|
+
const timerRef = (0, import_react3.useRef)();
|
|
314
|
+
const wrapRef = (0, import_react3.useRef)(null);
|
|
315
|
+
(0, import_react3.useEffect)(() => {
|
|
316
|
+
clearTimeout(timerRef.current);
|
|
317
|
+
if (!query.trim()) {
|
|
318
|
+
clear();
|
|
319
|
+
setOpen(false);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
timerRef.current = setTimeout(() => {
|
|
323
|
+
search(query, limit);
|
|
324
|
+
setOpen(true);
|
|
325
|
+
}, debounceMs);
|
|
326
|
+
return () => clearTimeout(timerRef.current);
|
|
327
|
+
}, [query]);
|
|
328
|
+
(0, import_react3.useEffect)(() => {
|
|
329
|
+
const handler = (e) => {
|
|
330
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
|
331
|
+
setOpen(false);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
document.addEventListener("mousedown", handler);
|
|
335
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
336
|
+
}, []);
|
|
337
|
+
const handleSelect = (r) => {
|
|
338
|
+
setOpen(false);
|
|
339
|
+
setQuery(r.product.name);
|
|
340
|
+
onSelect == null ? void 0 : onSelect(r);
|
|
341
|
+
};
|
|
342
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
343
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: DEFAULT_STYLES }),
|
|
344
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `huskel-search-wrap ${className != null ? className : ""}`, ref: wrapRef, children: [
|
|
345
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
346
|
+
"input",
|
|
347
|
+
{
|
|
348
|
+
className: "huskel-search-input",
|
|
349
|
+
type: "text",
|
|
350
|
+
value: query,
|
|
351
|
+
placeholder,
|
|
352
|
+
onChange: (e) => setQuery(e.target.value),
|
|
353
|
+
onFocus: () => results.length && setOpen(true)
|
|
354
|
+
}
|
|
355
|
+
),
|
|
356
|
+
open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "huskel-search-dropdown", children: [
|
|
357
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "huskel-search-loading", children: "Searching\u2026" }),
|
|
358
|
+
!loading && results.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "huskel-search-empty", children: [
|
|
359
|
+
'No results for "',
|
|
360
|
+
query,
|
|
361
|
+
'"'
|
|
362
|
+
] }),
|
|
363
|
+
results.map(
|
|
364
|
+
(r) => {
|
|
365
|
+
var _a, _b;
|
|
366
|
+
return renderResult ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { onClick: () => handleSelect(r), children: renderResult(r) }, r.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "huskel-search-item", onClick: () => handleSelect(r), children: [
|
|
367
|
+
((_a = r.product.images) == null ? void 0 : _a[0]) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: r.product.images[0], alt: r.product.name }),
|
|
368
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "huskel-search-item-info", children: [
|
|
369
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "huskel-search-item-name", children: r.product.name }),
|
|
370
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "huskel-search-item-price", children: [
|
|
371
|
+
(_b = r.product.currency) != null ? _b : "KES",
|
|
372
|
+
" ",
|
|
373
|
+
r.product.price
|
|
374
|
+
] })
|
|
375
|
+
] })
|
|
376
|
+
] }, r.id);
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
] })
|
|
380
|
+
] })
|
|
381
|
+
] });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/components/Sparkle.tsx
|
|
385
|
+
var import_react4 = require("react");
|
|
386
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
387
|
+
var STYLES = `
|
|
388
|
+
.huskel-sparkle-btn {
|
|
389
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
390
|
+
padding: 4px 10px; font-size: 12px; font-weight: 600;
|
|
391
|
+
background: #f47c3c; color: #fff; border: none;
|
|
392
|
+
border-radius: 20px; cursor: pointer; letter-spacing: 0.02em;
|
|
393
|
+
transition: opacity 0.2s, transform 0.15s;
|
|
394
|
+
}
|
|
395
|
+
.huskel-sparkle-btn:hover { opacity: 0.88; transform: scale(1.04); }
|
|
396
|
+
.huskel-sparkle-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
397
|
+
.huskel-sparkle-icon { font-size: 13px; }
|
|
398
|
+
`;
|
|
399
|
+
function Sparkle({ productName, className, onResult }) {
|
|
400
|
+
const [loading, setLoading] = (0, import_react4.useState)(false);
|
|
401
|
+
const handleClick = async () => {
|
|
402
|
+
setLoading(true);
|
|
403
|
+
try {
|
|
404
|
+
const client = getHuskelClient();
|
|
405
|
+
const res = await client.api.search(productName, 5);
|
|
406
|
+
onResult == null ? void 0 : onResult(res.results);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
console.error("[Huskel Sparkle]", e);
|
|
409
|
+
} finally {
|
|
410
|
+
setLoading(false);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
414
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("style", { children: STYLES }),
|
|
415
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
416
|
+
"button",
|
|
417
|
+
{
|
|
418
|
+
className: `huskel-sparkle-btn ${className != null ? className : ""}`,
|
|
419
|
+
onClick: handleClick,
|
|
420
|
+
disabled: loading,
|
|
421
|
+
title: "Find similar with AI",
|
|
422
|
+
children: [
|
|
423
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "huskel-sparkle-icon", children: "\u2726" }),
|
|
424
|
+
loading ? "Finding\u2026" : "Similar"
|
|
425
|
+
]
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
] });
|
|
429
|
+
}
|
|
430
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
431
|
+
0 && (module.exports = {
|
|
432
|
+
HuskelAPI,
|
|
433
|
+
HuskelClient,
|
|
434
|
+
SearchBar,
|
|
435
|
+
Sparkle,
|
|
436
|
+
getHuskelClient,
|
|
437
|
+
initHuskel,
|
|
438
|
+
useHuskel,
|
|
439
|
+
useSearch
|
|
440
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
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
|
+
// src/api.ts
|
|
19
|
+
var HuskelAPI = class {
|
|
20
|
+
constructor(apiUrl, siteId) {
|
|
21
|
+
this.apiUrl = apiUrl;
|
|
22
|
+
this.siteId = siteId;
|
|
23
|
+
}
|
|
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}`);
|
|
33
|
+
}
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
async ingest(product) {
|
|
37
|
+
return this.post("/ingest", { siteId: this.siteId, product });
|
|
38
|
+
}
|
|
39
|
+
async ingestBatch(products) {
|
|
40
|
+
return this.post("/ingest/batch", { siteId: this.siteId, products });
|
|
41
|
+
}
|
|
42
|
+
async search(query, limit = 10) {
|
|
43
|
+
return this.post("/search", { query, siteId: this.siteId, limit });
|
|
44
|
+
}
|
|
45
|
+
async pushConfig(config) {
|
|
46
|
+
return this.post("/sites/config", __spreadValues({ siteId: this.siteId }, config));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
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;
|
|
104
|
+
}
|
|
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());
|
|
113
|
+
}
|
|
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);
|
|
122
|
+
}
|
|
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
|
+
}
|
|
129
|
+
}
|
|
130
|
+
onChange(cb) {
|
|
131
|
+
this.callbacks.push(cb);
|
|
132
|
+
return () => {
|
|
133
|
+
this.callbacks = this.callbacks.filter((fn) => fn !== cb);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
destroy() {
|
|
137
|
+
this.callbacks = [];
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/client.ts
|
|
142
|
+
var HuskelClient = class {
|
|
143
|
+
constructor(config) {
|
|
144
|
+
this.config = config;
|
|
145
|
+
this.api = new HuskelAPI(config.apiUrl, config.siteId);
|
|
146
|
+
}
|
|
147
|
+
/** Push selectors config to backend once (idempotent) */
|
|
148
|
+
async configure() {
|
|
149
|
+
await this.api.pushConfig(this.config.selectors);
|
|
150
|
+
}
|
|
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;
|
|
159
|
+
}
|
|
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);
|
|
170
|
+
});
|
|
171
|
+
this.ingestCurrentPage().catch(console.error);
|
|
172
|
+
}
|
|
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);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
var instance = null;
|
|
181
|
+
function initHuskel(config) {
|
|
182
|
+
instance = new HuskelClient(config);
|
|
183
|
+
return instance;
|
|
184
|
+
}
|
|
185
|
+
function getHuskelClient() {
|
|
186
|
+
if (!instance) throw new Error("[Huskel] Call initHuskel() before using the client.");
|
|
187
|
+
return instance;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/hooks/useSearch.ts
|
|
191
|
+
import { useState, useCallback, useRef } from "react";
|
|
192
|
+
function useSearch() {
|
|
193
|
+
const [results, setResults] = useState([]);
|
|
194
|
+
const [loading, setLoading] = useState(false);
|
|
195
|
+
const [error, setError] = useState(null);
|
|
196
|
+
const abortRef = useRef(null);
|
|
197
|
+
const search = useCallback(async (query, limit = 10) => {
|
|
198
|
+
var _a, _b;
|
|
199
|
+
if (!query.trim()) {
|
|
200
|
+
setResults([]);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
(_a = abortRef.current) == null ? void 0 : _a.abort();
|
|
204
|
+
abortRef.current = new AbortController();
|
|
205
|
+
setLoading(true);
|
|
206
|
+
setError(null);
|
|
207
|
+
try {
|
|
208
|
+
const client = getHuskelClient();
|
|
209
|
+
const res = await client.api.search(query, limit);
|
|
210
|
+
setResults((_b = res.results) != null ? _b : []);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e.name !== "AbortError") {
|
|
213
|
+
setError(e.message);
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
setLoading(false);
|
|
217
|
+
}
|
|
218
|
+
}, []);
|
|
219
|
+
const clear = useCallback(() => {
|
|
220
|
+
setResults([]);
|
|
221
|
+
setError(null);
|
|
222
|
+
}, []);
|
|
223
|
+
return { results, loading, error, search, clear };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/hooks/useHuskel.ts
|
|
227
|
+
import { useEffect, useRef as useRef2 } from "react";
|
|
228
|
+
function useHuskel(options) {
|
|
229
|
+
const clientRef = useRef2(null);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
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;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 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; }
|
|
271
|
+
`;
|
|
272
|
+
function SearchBar({
|
|
273
|
+
placeholder = "Search for what you want \u2014 how you want",
|
|
274
|
+
limit = 10,
|
|
275
|
+
debounceMs = 300,
|
|
276
|
+
onSelect,
|
|
277
|
+
className,
|
|
278
|
+
renderResult
|
|
279
|
+
}) {
|
|
280
|
+
const [query, setQuery] = useState2("");
|
|
281
|
+
const [open, setOpen] = useState2(false);
|
|
282
|
+
const { results, loading, search, clear } = useSearch();
|
|
283
|
+
const timerRef = useRef3();
|
|
284
|
+
const wrapRef = useRef3(null);
|
|
285
|
+
useEffect2(() => {
|
|
286
|
+
clearTimeout(timerRef.current);
|
|
287
|
+
if (!query.trim()) {
|
|
288
|
+
clear();
|
|
289
|
+
setOpen(false);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
timerRef.current = setTimeout(() => {
|
|
293
|
+
search(query, limit);
|
|
294
|
+
setOpen(true);
|
|
295
|
+
}, debounceMs);
|
|
296
|
+
return () => clearTimeout(timerRef.current);
|
|
297
|
+
}, [query]);
|
|
298
|
+
useEffect2(() => {
|
|
299
|
+
const handler = (e) => {
|
|
300
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
|
301
|
+
setOpen(false);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
document.addEventListener("mousedown", handler);
|
|
305
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
306
|
+
}, []);
|
|
307
|
+
const handleSelect = (r) => {
|
|
308
|
+
setOpen(false);
|
|
309
|
+
setQuery(r.product.name);
|
|
310
|
+
onSelect == null ? void 0 : onSelect(r);
|
|
311
|
+
};
|
|
312
|
+
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(
|
|
316
|
+
"input",
|
|
317
|
+
{
|
|
318
|
+
className: "huskel-search-input",
|
|
319
|
+
type: "text",
|
|
320
|
+
value: query,
|
|
321
|
+
placeholder,
|
|
322
|
+
onChange: (e) => setQuery(e.target.value),
|
|
323
|
+
onFocus: () => results.length && setOpen(true)
|
|
324
|
+
}
|
|
325
|
+
),
|
|
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: [
|
|
329
|
+
'No results for "',
|
|
330
|
+
query,
|
|
331
|
+
'"'
|
|
332
|
+
] }),
|
|
333
|
+
results.map(
|
|
334
|
+
(r) => {
|
|
335
|
+
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: [
|
|
341
|
+
(_b = r.product.currency) != null ? _b : "KES",
|
|
342
|
+
" ",
|
|
343
|
+
r.product.price
|
|
344
|
+
] })
|
|
345
|
+
] })
|
|
346
|
+
] }, r.id);
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
] })
|
|
350
|
+
] })
|
|
351
|
+
] });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 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; }
|
|
368
|
+
`;
|
|
369
|
+
function Sparkle({ productName, className, onResult }) {
|
|
370
|
+
const [loading, setLoading] = useState3(false);
|
|
371
|
+
const handleClick = async () => {
|
|
372
|
+
setLoading(true);
|
|
373
|
+
try {
|
|
374
|
+
const client = getHuskelClient();
|
|
375
|
+
const res = await client.api.search(productName, 5);
|
|
376
|
+
onResult == null ? void 0 : onResult(res.results);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
console.error("[Huskel Sparkle]", e);
|
|
379
|
+
} finally {
|
|
380
|
+
setLoading(false);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
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
|
+
)
|
|
398
|
+
] });
|
|
399
|
+
}
|
|
400
|
+
export {
|
|
401
|
+
HuskelAPI,
|
|
402
|
+
HuskelClient,
|
|
403
|
+
SearchBar,
|
|
404
|
+
Sparkle,
|
|
405
|
+
getHuskelClient,
|
|
406
|
+
initHuskel,
|
|
407
|
+
useHuskel,
|
|
408
|
+
useSearch
|
|
409
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huskel/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Huskel AI-powered search SDK for SPAs",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.esm.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
11
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"react": ">=17",
|
|
15
|
+
"react-dom": ">=17"
|
|
16
|
+
},
|
|
17
|
+
"peerDependenciesMeta": {
|
|
18
|
+
"react": { "optional": true },
|
|
19
|
+
"react-dom": { "optional": true }
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/react": "^18.0.0",
|
|
23
|
+
"tsup": "^8.0.0",
|
|
24
|
+
"typescript": "^5.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|