@commerce-blocks/sdk 1.0.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/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/index.d.ts +1009 -0
- package/dist/index.js +2075 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
# @commerce-blocks/sdk
|
|
2
|
+
|
|
3
|
+
ES module SDK for Shopify storefronts with Layers API integration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @commerce-blocks/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createSdk } from '@commerce-blocks/sdk'
|
|
15
|
+
|
|
16
|
+
const result = createSdk({
|
|
17
|
+
// Layers API
|
|
18
|
+
layersPublicToken: 'your-layers-token',
|
|
19
|
+
sorts: [
|
|
20
|
+
{ label: 'Featured', code: 'featured' },
|
|
21
|
+
{ label: 'Price: Low to High', code: 'price_asc' },
|
|
22
|
+
],
|
|
23
|
+
facets: ['options.color', 'options.size', 'vendor'],
|
|
24
|
+
|
|
25
|
+
// Opt in to Shopify Storefront API for richer product data
|
|
26
|
+
// enableStorefront: true,
|
|
27
|
+
// shop: 'your-store.myshopify.com',
|
|
28
|
+
// storefrontPublicToken: 'your-storefront-token',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (result.error) {
|
|
32
|
+
console.error('SDK init failed:', result.error.message)
|
|
33
|
+
} else {
|
|
34
|
+
const sdk = result.data
|
|
35
|
+
// Ready to use
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
### Shopify (optional)
|
|
42
|
+
|
|
43
|
+
Storefront API is opt-in. Enable it for richer product data (metafields, full variant info, collection/page metadata).
|
|
44
|
+
|
|
45
|
+
| Option | Type | Required | Description |
|
|
46
|
+
| ----------------------- | ------------- | ----------------------------- | ---------------------------------------------------- |
|
|
47
|
+
| `enableStorefront` | `boolean` | No | Enable Storefront API hydration (default: `false`) |
|
|
48
|
+
| `shop` | `string` | When `enableStorefront: true` | Store domain |
|
|
49
|
+
| `storefrontPublicToken` | `string` | When `enableStorefront: true` | Storefront API public access token |
|
|
50
|
+
| `storefrontApiVersion` | `string` | No | API version (default: `2025-01`) |
|
|
51
|
+
| `fetch` | `CustomFetch` | No | Custom fetch implementation (SSR, testing, proxying) |
|
|
52
|
+
|
|
53
|
+
### Layers
|
|
54
|
+
|
|
55
|
+
| Option | Type | Required | Description |
|
|
56
|
+
| ------------------- | ------------- | -------- | ---------------------------------------------------- |
|
|
57
|
+
| `layersPublicToken` | `string` | Yes | Layers API public token |
|
|
58
|
+
| `sorts` | `Sort[]` | Yes | Sort options (`{ label, code }`) |
|
|
59
|
+
| `facets` | `string[]` | Yes | Facet fields for filtering |
|
|
60
|
+
| `attributes` | `string[]` | No | Product attributes to fetch |
|
|
61
|
+
| `layersBaseUrl` | `string` | No | Custom API URL |
|
|
62
|
+
| `fetch` | `CustomFetch` | No | Custom fetch implementation (SSR, testing, proxying) |
|
|
63
|
+
|
|
64
|
+
### Product
|
|
65
|
+
|
|
66
|
+
| Option | Type | Description |
|
|
67
|
+
| ------------------- | ------------------------------ | ----------------------------- |
|
|
68
|
+
| `currencyCode` | `string` | Currency for price formatting |
|
|
69
|
+
| `formatPrice` | `(amount, currency) => string` | Custom price formatter |
|
|
70
|
+
| `swatches` | `Swatch[]` | Color swatch definitions |
|
|
71
|
+
| `options` | `string[]` | Product options to expose |
|
|
72
|
+
| `productMetafields` | `{ namespace, key }[]` | Product metafields to fetch |
|
|
73
|
+
| `variantMetafields` | `{ namespace, key }[]` | Variant metafields to fetch |
|
|
74
|
+
|
|
75
|
+
### Extensibility
|
|
76
|
+
|
|
77
|
+
| Option | Type | Description |
|
|
78
|
+
| ------------------ | ------------------------------- | ---------------------------------- |
|
|
79
|
+
| `extendProduct` | `({ base, raw, shopify }) => P` | Transform products after hydration |
|
|
80
|
+
| `extendCollection` | `(result, raw) => result` | Transform collection results |
|
|
81
|
+
| `extendSearch` | `(result, raw) => result` | Transform search results |
|
|
82
|
+
| `transformFilters` | `(filters) => FilterGroup` | Custom filter transformation |
|
|
83
|
+
| `filterMap` | `FilterMap` | URL-friendly filter key mapping |
|
|
84
|
+
|
|
85
|
+
Once configured, extenders and transformers are applied automatically. You pass simple inputs and receive transformed outputs—no manual transformation needed on each call.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// Configure once at initialization
|
|
89
|
+
const sdk = createSdk({
|
|
90
|
+
// ... base config
|
|
91
|
+
extendProduct: ({ base, raw, shopify }) => ({
|
|
92
|
+
...base,
|
|
93
|
+
isNew: raw.tags?.includes('new') ?? false,
|
|
94
|
+
rating: raw.calculated?.average_rating,
|
|
95
|
+
}),
|
|
96
|
+
filterMap: {
|
|
97
|
+
color: 'options.color',
|
|
98
|
+
size: 'options.size',
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// All SDK methods automatically use your transformers
|
|
103
|
+
const collection = sdk.collection({ handle: 'shirts' })
|
|
104
|
+
await collection.execute({ filters: { color: 'Red' } }) // filterMap applied
|
|
105
|
+
// → result.products have isNew and rating properties
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Cache
|
|
109
|
+
|
|
110
|
+
| Option | Type | Description |
|
|
111
|
+
| ------------------ | -------- | -------------------------- |
|
|
112
|
+
| `cacheMaxProducts` | `number` | Max products in cache |
|
|
113
|
+
| `cacheMaxEntries` | `number` | Max query entries in cache |
|
|
114
|
+
| `cacheTtl` | `number` | TTL in milliseconds |
|
|
115
|
+
|
|
116
|
+
## SDK Methods
|
|
117
|
+
|
|
118
|
+
### `sdk.collection()` - Browse Collections
|
|
119
|
+
|
|
120
|
+
Creates a collection controller for browsing products in a collection. Controllers expose two ways to consume results:
|
|
121
|
+
|
|
122
|
+
- **Reactive**: Subscribe to `controller.state` (a `ReadonlySignal<QueryState<T>>`) via `effect()` — state updates automatically on each execute
|
|
123
|
+
- **Imperative**: `await controller.execute()` returns `Result<T, SdkError>` directly
|
|
124
|
+
|
|
125
|
+
This pattern applies to all controllers (`collection`, `search`, `blocks`).
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { effect } from '@preact/signals-core'
|
|
129
|
+
|
|
130
|
+
const collection = sdk.collection({
|
|
131
|
+
handle: 'shirts',
|
|
132
|
+
defaultSort: 'featured', // optional, uses first sort if omitted
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Subscribe to state changes
|
|
136
|
+
effect(() => {
|
|
137
|
+
const { data, error, isFetching } = collection.state.value
|
|
138
|
+
if (isFetching) console.log('Loading...')
|
|
139
|
+
if (error) console.error('Error:', error.message)
|
|
140
|
+
if (data) console.log('Products:', data.products)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Execute queries — returns Result<CollectionResult, SdkError>
|
|
144
|
+
const result = await collection.execute() // initial load
|
|
145
|
+
if (result.error) console.error(result.error.message)
|
|
146
|
+
if (result.data) console.log('Got', result.data.totalResults, 'results')
|
|
147
|
+
|
|
148
|
+
await collection.execute({ page: 2 }) // pagination
|
|
149
|
+
await collection.execute({ sortOrderCode: 'price_asc' }) // change sort
|
|
150
|
+
await collection.execute({ filters: { color: 'Red' } }) // with filters
|
|
151
|
+
|
|
152
|
+
// Cleanup
|
|
153
|
+
collection.dispose()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Options:**
|
|
157
|
+
|
|
158
|
+
| Parameter | Type | Required | Description |
|
|
159
|
+
| ------------- | -------- | -------- | ---------------------------------------------- |
|
|
160
|
+
| `handle` | `string` | Yes | Collection URL handle |
|
|
161
|
+
| `defaultSort` | `string` | No | Default sort code (uses first configured sort) |
|
|
162
|
+
|
|
163
|
+
**Execute parameters:**
|
|
164
|
+
|
|
165
|
+
| Parameter | Type | Description |
|
|
166
|
+
| --------------- | ------------- | ------------------------------- |
|
|
167
|
+
| `page` | `number` | Page number (default: 1) |
|
|
168
|
+
| `limit` | `number` | Products per page (default: 24) |
|
|
169
|
+
| `sortOrderCode` | `string` | Sort option code |
|
|
170
|
+
| `filters` | `unknown` | Filter criteria |
|
|
171
|
+
| `signal` | `AbortSignal` | External abort signal |
|
|
172
|
+
|
|
173
|
+
### `sdk.blocks()` - Product Recommendations
|
|
174
|
+
|
|
175
|
+
Creates a blocks controller for product recommendations powered by Layers blocks. Blocks can be anchored to a product, collection, or cart for contextual recommendations.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const blocks = sdk.blocks({
|
|
179
|
+
blockId: 'block-abc123',
|
|
180
|
+
anchorHandle: 'gold-necklace', // anchor by product handle
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Subscribe to state changes
|
|
184
|
+
effect(() => {
|
|
185
|
+
const { data, error, isFetching } = blocks.state.value
|
|
186
|
+
if (data) {
|
|
187
|
+
console.log('Recommendations:', data.products)
|
|
188
|
+
console.log('Block info:', data.block) // { title, anchor_type, strategy_type, ... }
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Execute queries
|
|
193
|
+
await blocks.execute()
|
|
194
|
+
await blocks.execute({ page: 2, limit: 12 })
|
|
195
|
+
|
|
196
|
+
// With cart context for personalized recommendations
|
|
197
|
+
await blocks.execute({
|
|
198
|
+
context: {
|
|
199
|
+
productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
|
|
200
|
+
geo: { country: 'US' },
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// With discount entitlements
|
|
205
|
+
await blocks.execute({
|
|
206
|
+
discountEntitlements: [{ id: 'discount-123' }],
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Cleanup
|
|
210
|
+
blocks.dispose()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Options:**
|
|
214
|
+
|
|
215
|
+
| Parameter | Type | Required | Description |
|
|
216
|
+
| -------------- | -------- | -------- | -------------------------------- |
|
|
217
|
+
| `blockId` | `string` | Yes | Layers block ID |
|
|
218
|
+
| `anchorId` | `string` | No | Anchor product/collection ID |
|
|
219
|
+
| `anchorHandle` | `string` | No | Anchor product/collection handle |
|
|
220
|
+
|
|
221
|
+
**Execute parameters:**
|
|
222
|
+
|
|
223
|
+
| Parameter | Type | Description |
|
|
224
|
+
| ---------------------- | ----------------------- | ------------------------------- |
|
|
225
|
+
| `page` | `number` | Page number (default: 1) |
|
|
226
|
+
| `limit` | `number` | Products per page (default: 24) |
|
|
227
|
+
| `filters` | `unknown` | Filter criteria |
|
|
228
|
+
| `signal` | `AbortSignal` | External abort signal |
|
|
229
|
+
| `discountEntitlements` | `DiscountEntitlement[]` | Discount entitlements to apply |
|
|
230
|
+
| `context` | `BlocksContext` | Cart, geo, and custom context |
|
|
231
|
+
|
|
232
|
+
**`BlocksContext`:**
|
|
233
|
+
|
|
234
|
+
| Property | Type | Description |
|
|
235
|
+
| ---------------- | ---------------------------------------- | -------------------- |
|
|
236
|
+
| `productsInCart` | `{ productId, variantId?, quantity? }[]` | Products in the cart |
|
|
237
|
+
| `geo` | `{ country?, province?, city? }` | Geographic context |
|
|
238
|
+
| `custom` | `Record<string, unknown>` | Custom context data |
|
|
239
|
+
|
|
240
|
+
### `sdk.autocomplete()` - Predictive Search
|
|
241
|
+
|
|
242
|
+
Creates a standalone autocomplete controller with debounced search and local caching. Only full words (completed by a trailing space) are cached — partial input filters cached results client-side.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
const autocomplete = sdk.autocomplete({ debounceMs: 300 })
|
|
246
|
+
|
|
247
|
+
// Subscribe to state
|
|
248
|
+
effect(() => {
|
|
249
|
+
const { data, isFetching, error } = autocomplete.state.value
|
|
250
|
+
if (isFetching) console.log('Loading suggestions...')
|
|
251
|
+
if (data) renderSuggestions(data.matchedQueries)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Wire to input (debounced automatically)
|
|
255
|
+
input.addEventListener('input', (e) => {
|
|
256
|
+
autocomplete.execute(e.target.value)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Cleanup
|
|
260
|
+
autocomplete.dispose()
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Options:**
|
|
264
|
+
|
|
265
|
+
| Parameter | Type | Description |
|
|
266
|
+
| ------------ | ------------- | ---------------------------------------------------------- |
|
|
267
|
+
| `debounceMs` | `number` | Debounce delay (default: 300) |
|
|
268
|
+
| `signal` | `AbortSignal` | External abort signal (acts like `dispose()` when aborted) |
|
|
269
|
+
|
|
270
|
+
**Controller methods:**
|
|
271
|
+
|
|
272
|
+
| Method | Description |
|
|
273
|
+
| ---------------- | -------------------------------------------- |
|
|
274
|
+
| `execute(query)` | Debounced predictive search for autocomplete |
|
|
275
|
+
| `dispose()` | Cleanup abort controller and timers |
|
|
276
|
+
|
|
277
|
+
### `sdk.search()` - Search Products
|
|
278
|
+
|
|
279
|
+
Creates a search controller for full text search with prepare/execute flow.
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
const search = sdk.search()
|
|
283
|
+
|
|
284
|
+
// Subscribe to state
|
|
285
|
+
effect(() => {
|
|
286
|
+
const { data, isFetching, error } = search.state.value
|
|
287
|
+
if (isFetching) console.log('Searching...')
|
|
288
|
+
if (data) renderResults(data.products)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// Prepare search (optional, caches searchId for reuse)
|
|
292
|
+
await search.prepare({ query: 'ring' })
|
|
293
|
+
|
|
294
|
+
// Execute search with pagination and filters
|
|
295
|
+
form.addEventListener('submit', async (e) => {
|
|
296
|
+
e.preventDefault()
|
|
297
|
+
const result = await search.execute({
|
|
298
|
+
query: inputValue,
|
|
299
|
+
limit: 20,
|
|
300
|
+
filters: { color: 'Blue' },
|
|
301
|
+
})
|
|
302
|
+
if (result.data) console.log('Results:', result.data.products)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Cleanup
|
|
306
|
+
search.dispose()
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Controller methods:**
|
|
310
|
+
|
|
311
|
+
| Method | Description |
|
|
312
|
+
| ---------------- | -------------------------------------------------- |
|
|
313
|
+
| `prepare(query)` | Prepare search and cache searchId for reuse |
|
|
314
|
+
| `execute(query)` | Execute search (uses cached searchId if available) |
|
|
315
|
+
| `dispose()` | Cleanup abort controller |
|
|
316
|
+
|
|
317
|
+
**Search parameters:**
|
|
318
|
+
|
|
319
|
+
| Parameter | Type | Description |
|
|
320
|
+
| --------- | -------------- | ------------------------------- |
|
|
321
|
+
| `query` | `string` | Search query |
|
|
322
|
+
| `page` | `number` | Page number (default: 1) |
|
|
323
|
+
| `limit` | `number` | Products per page (default: 24) |
|
|
324
|
+
| `filters` | `unknown` | Filter criteria |
|
|
325
|
+
| `tuning` | `LayersTuning` | Search tuning parameters |
|
|
326
|
+
| `signal` | `AbortSignal` | External abort signal |
|
|
327
|
+
|
|
328
|
+
### `sdk.uploadImage()` - Upload Image for Search
|
|
329
|
+
|
|
330
|
+
Returns a reactive signal that uploads an image and resolves to an image ID for use with `imageSearch()`. Subscribe with `effect()` like other signal-based methods.
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
const fileInput = document.querySelector('input[type="file"]')
|
|
334
|
+
const file = fileInput.files[0]
|
|
335
|
+
|
|
336
|
+
const state = sdk.uploadImage({ image: file, signal })
|
|
337
|
+
|
|
338
|
+
effect(() => {
|
|
339
|
+
const { data, error, isFetching } = state.value
|
|
340
|
+
if (isFetching) console.log('Uploading...')
|
|
341
|
+
if (error) console.error('Upload failed:', error.message)
|
|
342
|
+
if (data) console.log('Image ID:', data.imageId)
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### `sdk.imageSearch()` - Search by Image
|
|
347
|
+
|
|
348
|
+
Returns a reactive signal that searches products using a previously uploaded image. Same signal-based pattern as `uploadImage()` and `storefront()`.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const state = sdk.imageSearch({
|
|
352
|
+
imageId: 'uploaded-image-id',
|
|
353
|
+
limit: 20,
|
|
354
|
+
filters: { vendor: 'Nike' },
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
effect(() => {
|
|
358
|
+
const { data, error, isFetching } = state.value
|
|
359
|
+
if (data) console.log('Similar products:', data.products)
|
|
360
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Parameters:**
|
|
364
|
+
|
|
365
|
+
| Parameter | Type | Required | Description |
|
|
366
|
+
| --------- | -------------- | -------- | ------------------------------- |
|
|
367
|
+
| `imageId` | `string` | Yes | Image ID from `uploadImage()` |
|
|
368
|
+
| `page` | `number` | No | Page number (default: 1) |
|
|
369
|
+
| `limit` | `number` | No | Products per page (default: 24) |
|
|
370
|
+
| `filters` | `unknown` | No | Filter criteria |
|
|
371
|
+
| `tuning` | `LayersTuning` | No | Search tuning parameters |
|
|
372
|
+
| `signal` | `AbortSignal` | No | External abort signal |
|
|
373
|
+
|
|
374
|
+
### `sdk.storefront()` - Fetch Products and Metadata
|
|
375
|
+
|
|
376
|
+
Returns a reactive signal for fetching products by their Shopify GIDs, with optional collection/page metadata.
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
const state = sdk.storefront({
|
|
380
|
+
ids: ['gid://shopify/Product/123', 'gid://shopify/Product/456'],
|
|
381
|
+
meta: {
|
|
382
|
+
collection: 'shirts', // optional: fetch collection metadata
|
|
383
|
+
page: 'about', // optional: fetch page metadata
|
|
384
|
+
includeFilters: true, // optional: include available filters
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
effect(() => {
|
|
389
|
+
const { data, error, isFetching } = state.value
|
|
390
|
+
if (data) {
|
|
391
|
+
console.log('Products:', data.products)
|
|
392
|
+
console.log('Collection:', data.collection)
|
|
393
|
+
console.log('Page:', data.page)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Parameters:**
|
|
399
|
+
|
|
400
|
+
| Parameter | Type | Required | Description |
|
|
401
|
+
| --------------------- | ------------- | -------- | ---------------------------------------- |
|
|
402
|
+
| `ids` | `string[]` | Yes | Shopify Product GIDs |
|
|
403
|
+
| `meta.collection` | `string` | No | Collection handle to fetch metadata |
|
|
404
|
+
| `meta.page` | `string` | No | Page handle to fetch metadata |
|
|
405
|
+
| `meta.includeFilters` | `boolean` | No | Include available filters for collection |
|
|
406
|
+
| `signal` | `AbortSignal` | No | External abort signal |
|
|
407
|
+
|
|
408
|
+
## Abort Signals
|
|
409
|
+
|
|
410
|
+
All SDK methods accept an optional `signal` for external cancellation. This works alongside the SDK's internal abort logic (e.g., a new search call automatically cancels the previous one).
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
const controller = new AbortController()
|
|
414
|
+
|
|
415
|
+
// Cancel from outside
|
|
416
|
+
await search.execute({ query: 'ring', signal: controller.signal })
|
|
417
|
+
controller.abort() // cancels the request
|
|
418
|
+
|
|
419
|
+
// With autocomplete — signal acts like dispose()
|
|
420
|
+
const autocomplete = sdk.autocomplete({ signal: controller.signal })
|
|
421
|
+
controller.abort() // cancels debounce + pending request
|
|
422
|
+
|
|
423
|
+
// With collection
|
|
424
|
+
await collection.execute({ page: 2, signal: controller.signal })
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
For controllers (autocomplete, search, collection), calling a new query still auto-cancels the previous request. The external signal adds an additional cancellation path — useful for component unmount or navigation.
|
|
428
|
+
|
|
429
|
+
## Response Types
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
// All controllers use QueryState<T> for reactive state
|
|
433
|
+
interface QueryState<T> {
|
|
434
|
+
data: T | null
|
|
435
|
+
error: SdkError | null
|
|
436
|
+
isFetching: boolean
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface CollectionResult {
|
|
440
|
+
products: Product[]
|
|
441
|
+
totalResults: number
|
|
442
|
+
totalPages: number
|
|
443
|
+
page: number
|
|
444
|
+
facets: Record<string, Record<string, number>>
|
|
445
|
+
collection?: ShopifyCollection
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
interface SearchResult {
|
|
449
|
+
products: Product[]
|
|
450
|
+
totalResults: number
|
|
451
|
+
totalPages: number
|
|
452
|
+
page: number
|
|
453
|
+
facets: Record<string, Record<string, number>>
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface BlocksResult {
|
|
457
|
+
products: Product[]
|
|
458
|
+
totalResults: number
|
|
459
|
+
totalPages: number
|
|
460
|
+
page: number
|
|
461
|
+
facets: Record<string, Record<string, number>>
|
|
462
|
+
block?: BlocksInfo // { id, title, anchor_type, strategy_type, strategy_key }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
interface StorefrontResult {
|
|
466
|
+
products: Product[]
|
|
467
|
+
collection?: ShopifyCollection
|
|
468
|
+
page?: ShopifyPage
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Error Handling
|
|
473
|
+
|
|
474
|
+
All methods return `Result<T, E>` instead of throwing. Errors are discriminated by `_tag`:
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
const result = await collection.execute()
|
|
478
|
+
|
|
479
|
+
if (result.error) {
|
|
480
|
+
switch (result.error._tag) {
|
|
481
|
+
case 'NetworkError':
|
|
482
|
+
// Connection issues, timeouts, aborted requests
|
|
483
|
+
console.log(result.error.code) // 'TIMEOUT' | 'CONNECTION_FAILED' | 'ABORTED' | ...
|
|
484
|
+
break
|
|
485
|
+
case 'ApiError':
|
|
486
|
+
// Server errors, rate limits
|
|
487
|
+
console.log(result.error.source) // 'layers' | 'shopify'
|
|
488
|
+
console.log(result.error.status) // HTTP status code
|
|
489
|
+
break
|
|
490
|
+
case 'ValidationError':
|
|
491
|
+
// Invalid parameters
|
|
492
|
+
console.log(result.error.operation) // which method failed
|
|
493
|
+
console.log(result.error.fields) // [{ field, code, message }]
|
|
494
|
+
break
|
|
495
|
+
case 'ConfigError':
|
|
496
|
+
// SDK configuration issues
|
|
497
|
+
console.log(result.error.field) // which config field
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
const data = result.data
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Error Types
|
|
506
|
+
|
|
507
|
+
**`NetworkError`** — connection failures, timeouts, aborted requests
|
|
508
|
+
|
|
509
|
+
| Field | Type | Description |
|
|
510
|
+
| -------------- | ------------------ | ------------------------------------------------------------------------------- |
|
|
511
|
+
| `code` | `NetworkErrorCode` | `TIMEOUT`, `CONNECTION_FAILED`, `DNS_FAILED`, `SSL_ERROR`, `ABORTED`, `OFFLINE` |
|
|
512
|
+
| `message` | `string` | Human-readable description |
|
|
513
|
+
| `retryable` | `boolean` | Whether the request can be retried |
|
|
514
|
+
| `retryAfterMs` | `number?` | Suggested retry delay in milliseconds |
|
|
515
|
+
|
|
516
|
+
**`ApiError`** — server errors, rate limits, GraphQL errors
|
|
517
|
+
|
|
518
|
+
| Field | Type | Description |
|
|
519
|
+
| -------------- | -------------- | -------------------------------------------------- |
|
|
520
|
+
| `code` | `ApiErrorCode` | `NOT_FOUND`, `RATE_LIMITED`, `GRAPHQL_ERROR`, etc. |
|
|
521
|
+
| `source` | `ApiSource` | `'layers'` or `'shopify'` |
|
|
522
|
+
| `status` | `number?` | HTTP status code |
|
|
523
|
+
| `retryable` | `boolean` | Whether the request can be retried |
|
|
524
|
+
| `retryAfterMs` | `number?` | Suggested retry delay |
|
|
525
|
+
|
|
526
|
+
**`ValidationError`** — invalid parameters passed to SDK methods
|
|
527
|
+
|
|
528
|
+
| Field | Type | Description |
|
|
529
|
+
| ----------- | ------------------------ | ------------------------------------- |
|
|
530
|
+
| `operation` | `string` | Which method failed (e.g. `'search'`) |
|
|
531
|
+
| `fields` | `ValidationFieldError[]` | `[{ field, code, message }]` |
|
|
532
|
+
|
|
533
|
+
**`ConfigError`** — SDK configuration issues at init time
|
|
534
|
+
|
|
535
|
+
| Field | Type | Description |
|
|
536
|
+
| ---------- | --------- | --------------------------- |
|
|
537
|
+
| `field` | `string` | Which config field is wrong |
|
|
538
|
+
| `expected` | `string?` | What was expected |
|
|
539
|
+
|
|
540
|
+
### Error Helpers
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
import { isRetryable, getRetryDelay } from '@commerce-blocks/sdk'
|
|
544
|
+
|
|
545
|
+
if (result.error && isRetryable(result.error)) {
|
|
546
|
+
const delay = getRetryDelay(result.error) ?? 1000
|
|
547
|
+
setTimeout(() => collection.execute(), delay)
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## Filtering
|
|
552
|
+
|
|
553
|
+
Build filters using the DSL helpers:
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
import { filter, and, or, eq, inValues, gte, lte } from '@commerce-blocks/sdk'
|
|
557
|
+
|
|
558
|
+
// Single filter
|
|
559
|
+
const colorFilter = filter(eq('options.color', 'Red'))
|
|
560
|
+
|
|
561
|
+
// Multiple conditions (AND)
|
|
562
|
+
const multiFilter = filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium')))
|
|
563
|
+
|
|
564
|
+
// Multiple values (OR)
|
|
565
|
+
const multiValue = filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))
|
|
566
|
+
|
|
567
|
+
// Price range
|
|
568
|
+
const priceFilter = filter(and(gte('price', 50), lte('price', 200)))
|
|
569
|
+
|
|
570
|
+
// Use in query
|
|
571
|
+
await collection.execute({
|
|
572
|
+
filters: multiFilter.filter_group,
|
|
573
|
+
})
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Filter Operators
|
|
577
|
+
|
|
578
|
+
| Function | Description |
|
|
579
|
+
| ------------------------------ | ----------------------- |
|
|
580
|
+
| `eq(property, value)` | Equals |
|
|
581
|
+
| `notEq(property, value)` | Not equals |
|
|
582
|
+
| `inValues(property, values[])` | In list |
|
|
583
|
+
| `notIn(property, values[])` | Not in list |
|
|
584
|
+
| `gt(property, number)` | Greater than |
|
|
585
|
+
| `gte(property, number)` | Greater than or equal |
|
|
586
|
+
| `lt(property, number)` | Less than |
|
|
587
|
+
| `lte(property, number)` | Less than or equal |
|
|
588
|
+
| `exists(property)` | Property exists |
|
|
589
|
+
| `notExists(property)` | Property does not exist |
|
|
590
|
+
|
|
591
|
+
### Filter Mapping
|
|
592
|
+
|
|
593
|
+
Use `filterMap` for URL-friendly filter keys:
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
const sdk = createSdk({
|
|
597
|
+
// ... other config
|
|
598
|
+
filterMap: {
|
|
599
|
+
color: 'options.color',
|
|
600
|
+
size: 'options.size',
|
|
601
|
+
brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
|
|
602
|
+
},
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Now you can use simple keys
|
|
606
|
+
await collection.execute({
|
|
607
|
+
filters: { color: 'Red', brand: 'nike' },
|
|
608
|
+
})
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
## Hooks
|
|
612
|
+
|
|
613
|
+
### `useProductCard()` - Product Card Helper
|
|
614
|
+
|
|
615
|
+
Utility for building product cards with variant selection and availability logic.
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
import { useProductCard } from '@commerce-blocks/sdk'
|
|
619
|
+
|
|
620
|
+
const card = useProductCard({
|
|
621
|
+
product,
|
|
622
|
+
selectedOptions: [{ name: 'Size', value: 'M' }],
|
|
623
|
+
breakAwayOptions: [{ name: 'Color', value: 'Red' }],
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
// Access computed data
|
|
627
|
+
card.variants // Variants filtered by breakAwayOptions
|
|
628
|
+
card.selectedVariant // Currently selected variant
|
|
629
|
+
card.options // Available options (excludes breakaway option names)
|
|
630
|
+
card.images // Variant image or product images
|
|
631
|
+
card.carouselIndex // Image index for carousel
|
|
632
|
+
|
|
633
|
+
// Helper methods
|
|
634
|
+
card.getOptionValues('Size') // ['S', 'M', 'L']
|
|
635
|
+
card.getSwatches('Color') // Swatch definitions
|
|
636
|
+
card.isOptionAvailable('Size', 'L') // Check if selecting 'L' results in available variant
|
|
637
|
+
card.getVariantByOptions([{ name: 'Size', value: 'L' }])
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**Parameters:**
|
|
641
|
+
|
|
642
|
+
| Parameter | Type | Required | Description |
|
|
643
|
+
| ------------------ | ----------------- | -------- | ------------------------------------------------------------- |
|
|
644
|
+
| `product` | `Product` | Yes | Product to display |
|
|
645
|
+
| `selectedOptions` | `ProductOption[]` | No | Currently selected options |
|
|
646
|
+
| `breakAwayOptions` | `ProductOption[]` | No | Options to filter visible variants (e.g., pre-selected color) |
|
|
647
|
+
|
|
648
|
+
**Returns:**
|
|
649
|
+
|
|
650
|
+
| Property | Type | Description |
|
|
651
|
+
| ----------------- | ------------------------ | --------------------------------------- |
|
|
652
|
+
| `product` | `Product` | Original product |
|
|
653
|
+
| `variants` | `ProductVariant[]` | Variants matching breakAwayOptions |
|
|
654
|
+
| `selectedVariant` | `ProductVariant \| null` | Variant matching all selected options |
|
|
655
|
+
| `selectedOptions` | `ProductOption[]` | Combined breakaway + selected options |
|
|
656
|
+
| `options` | `RichProductOption[]` | Available options from visible variants |
|
|
657
|
+
| `optionNames` | `string[]` | Option names (excludes breakaway) |
|
|
658
|
+
| `images` | `Image[]` | Variant image or product images |
|
|
659
|
+
| `carouselIndex` | `number` | Index of selected variant's image |
|
|
660
|
+
|
|
661
|
+
**Helper Methods:**
|
|
662
|
+
|
|
663
|
+
| Method | Returns | Description |
|
|
664
|
+
| -------------------------------- | ------------------------ | ------------------------------------------------------ |
|
|
665
|
+
| `getOptionValues(name)` | `string[]` | All values for an option from visible variants |
|
|
666
|
+
| `getSwatches(name)` | `Swatch[]` | Swatch definitions for an option |
|
|
667
|
+
| `getVariantByOptions(opts)` | `ProductVariant \| null` | Find variant by options |
|
|
668
|
+
| `isOptionAvailable(name, value)` | `boolean` | Check if selecting option results in available variant |
|
|
669
|
+
| `isVariantAvailable(variant)` | `boolean` | Check if variant is available for sale |
|
|
670
|
+
|
|
671
|
+
## Singleton Access
|
|
672
|
+
|
|
673
|
+
After initialization, access the SDK anywhere:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import { getSdk, isInitialized } from '@commerce-blocks/sdk'
|
|
677
|
+
|
|
678
|
+
if (isInitialized()) {
|
|
679
|
+
const result = getSdk()
|
|
680
|
+
if (result.data) {
|
|
681
|
+
const sdk = result.data
|
|
682
|
+
// Use sdk
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
## Store API
|
|
688
|
+
|
|
689
|
+
The SDK exposes a reactive store for caching:
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
const { store } = sdk
|
|
693
|
+
|
|
694
|
+
// Product cache
|
|
695
|
+
const product = store.products.get('gid://shopify/Product/123')
|
|
696
|
+
const { cached, missing } = store.products.getMany(gids)
|
|
697
|
+
store.products.set(products)
|
|
698
|
+
|
|
699
|
+
// Query cache (reactive)
|
|
700
|
+
const querySignal = store.queries.get('cache-key')
|
|
701
|
+
store.queries.invalidate('browse:*') // invalidate by pattern
|
|
702
|
+
|
|
703
|
+
// Collection metadata
|
|
704
|
+
const meta = store.collections.get('shirts')
|
|
705
|
+
|
|
706
|
+
// Persistence
|
|
707
|
+
store.persist() // save to localStorage
|
|
708
|
+
store.restore() // restore from localStorage
|
|
709
|
+
store.clear() // clear all caches
|
|
710
|
+
|
|
711
|
+
// Stats
|
|
712
|
+
console.log(store.stats) // { products, queries, collections }
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
## Standalone Utilities
|
|
716
|
+
|
|
717
|
+
### `search()` - Direct Layers Search
|
|
718
|
+
|
|
719
|
+
For advanced use cases bypassing SDK caching. Combines `prepareSearch` + `search` into a single call.
|
|
720
|
+
|
|
721
|
+
```typescript
|
|
722
|
+
import { layersClient, search } from '@commerce-blocks/sdk'
|
|
723
|
+
|
|
724
|
+
const clientResult = layersClient({
|
|
725
|
+
layersPublicToken: 'your-token',
|
|
726
|
+
sorts: [{ label: 'Featured', code: 'featured' }],
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
if (clientResult.data) {
|
|
730
|
+
const result = await search(clientResult.data, 'red dress', {
|
|
731
|
+
pagination: { page: 1, limit: 20 },
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
if (result.data) {
|
|
735
|
+
console.log('Raw Layers results:', result.data.results)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Cache Key Generators
|
|
741
|
+
|
|
742
|
+
Build cache keys for manual store operations:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
import { browseKey, searchKey, similarKey, blocksKey, productsKey } from '@commerce-blocks/sdk'
|
|
746
|
+
|
|
747
|
+
browseKey('shirts', { page: 1, limit: 24 }) // '/browse/shirts?limit=24&page=1'
|
|
748
|
+
searchKey('red dress', { page: 2 }) // '/search/red%20dress?page=2'
|
|
749
|
+
similarKey(123, { limit: 10 }) // '/similar/123?limit=10'
|
|
750
|
+
blocksKey('block-abc', { anchorHandle: 'gold-necklace', page: 1 })
|
|
751
|
+
productsKey(['gid://shopify/Product/1', 'gid://shopify/Product/2'])
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
## Testing
|
|
755
|
+
|
|
756
|
+
Tests use Playwright running in a real browser context. The SDK is loaded via a test harness page and accessed through `window.SDK`.
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
npm run test # run all tests
|
|
760
|
+
npm run test:ui # interactive UI
|
|
761
|
+
npx playwright test tests/client.spec.ts # single file
|
|
762
|
+
npx playwright test tests/layers.spec.ts -g "search" # single test
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Test helpers
|
|
766
|
+
|
|
767
|
+
- `setupSdk(page)` — navigates to the test harness and waits for `window.SDK_READY`
|
|
768
|
+
- `inBrowser(page, fn)` — runs a function inside the browser via `page.evaluate()`
|
|
769
|
+
|
|
770
|
+
### Writing tests
|
|
771
|
+
|
|
772
|
+
Tests run SDK code inside the browser and return serializable results for assertions:
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
import { test, expect } from '@playwright/test'
|
|
776
|
+
import { setupSdk, inBrowser } from './helpers'
|
|
777
|
+
|
|
778
|
+
test.describe('SDK: feature()', () => {
|
|
779
|
+
test.beforeEach(async ({ page }) => {
|
|
780
|
+
await setupSdk(page)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
test('does something', async ({ page }) => {
|
|
784
|
+
const result = await inBrowser(page, async () => {
|
|
785
|
+
const { createSdk, devConfig } = (window as any).SDK
|
|
786
|
+
const { data: sdk } = createSdk(devConfig)
|
|
787
|
+
if (!sdk) return { error: 'init failed' }
|
|
788
|
+
|
|
789
|
+
const controller = sdk.collection({ handle: 'shirts' })
|
|
790
|
+
const { data, error } = await controller.execute({ limit: 3 })
|
|
791
|
+
controller.dispose()
|
|
792
|
+
|
|
793
|
+
if (error) return { error: error._tag }
|
|
794
|
+
return { count: data.products.length }
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
expect(result.error).toBeUndefined()
|
|
798
|
+
expect(result.count).toBeGreaterThan(0)
|
|
799
|
+
})
|
|
800
|
+
})
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
Key patterns:
|
|
804
|
+
|
|
805
|
+
- All SDK calls happen inside `inBrowser()` — you can't access SDK objects directly in Node
|
|
806
|
+
- Return plain objects from `inBrowser()`, not class instances or signals
|
|
807
|
+
- Always `dispose()` controllers to prevent leaks between tests
|
|
808
|
+
- Use `devConfig` from the test harness for real API credentials
|
|
809
|
+
|
|
810
|
+
## Technical Details
|
|
811
|
+
|
|
812
|
+
- **Runtime**: Browser (ESM)
|
|
813
|
+
- **TypeScript**: Full type definitions included
|
|
814
|
+
- **Dependencies**: `@preact/signals-core`
|
|
815
|
+
- **Bundle**: Tree-shakeable ES modules
|
|
816
|
+
- **Caching**: Built-in LRU cache with configurable TTL
|