@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/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