@tiledev/sdk-shopify 0.9.1 → 0.10.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.
Files changed (143) hide show
  1. package/README.md +650 -50
  2. package/dist/alertSettings.d.ts.map +1 -1
  3. package/dist/alertSettings.js +2 -0
  4. package/dist/alertSettings.js.map +1 -1
  5. package/dist/attribution.d.ts +21 -0
  6. package/dist/attribution.d.ts.map +1 -1
  7. package/dist/attribution.js +17 -0
  8. package/dist/attribution.js.map +1 -1
  9. package/dist/auth/pkce.d.ts +30 -0
  10. package/dist/auth/pkce.d.ts.map +1 -0
  11. package/dist/auth/pkce.js +128 -0
  12. package/dist/auth/pkce.js.map +1 -0
  13. package/dist/auth/session.d.ts +118 -0
  14. package/dist/auth/session.d.ts.map +1 -0
  15. package/dist/auth/session.js +896 -0
  16. package/dist/auth/session.js.map +1 -0
  17. package/dist/auth/storeCredit.d.ts +64 -0
  18. package/dist/auth/storeCredit.d.ts.map +1 -0
  19. package/dist/auth/storeCredit.js +274 -0
  20. package/dist/auth/storeCredit.js.map +1 -0
  21. package/dist/cart.d.ts +25 -1
  22. package/dist/cart.d.ts.map +1 -1
  23. package/dist/cart.js +95 -2
  24. package/dist/cart.js.map +1 -1
  25. package/dist/cartPolicy.js +4 -4
  26. package/dist/checkout.d.ts +123 -0
  27. package/dist/checkout.d.ts.map +1 -0
  28. package/dist/checkout.js +205 -0
  29. package/dist/checkout.js.map +1 -0
  30. package/dist/customer.d.ts.map +1 -1
  31. package/dist/customer.js +8 -0
  32. package/dist/customer.js.map +1 -1
  33. package/dist/index.d.ts +12 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +74 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/messages.d.ts.map +1 -1
  38. package/dist/messages.js +2 -0
  39. package/dist/messages.js.map +1 -1
  40. package/dist/money.d.ts +16 -0
  41. package/dist/money.d.ts.map +1 -1
  42. package/dist/money.js +55 -0
  43. package/dist/money.js.map +1 -1
  44. package/dist/orders.d.ts +99 -0
  45. package/dist/orders.d.ts.map +1 -0
  46. package/dist/orders.js +439 -0
  47. package/dist/orders.js.map +1 -0
  48. package/dist/productPage.d.ts +100 -0
  49. package/dist/productPage.d.ts.map +1 -0
  50. package/dist/productPage.js +298 -0
  51. package/dist/productPage.js.map +1 -0
  52. package/dist/productStore.d.ts +1 -1
  53. package/dist/productStore.d.ts.map +1 -1
  54. package/dist/productStore.js +63 -5
  55. package/dist/productStore.js.map +1 -1
  56. package/dist/products.d.ts.map +1 -1
  57. package/dist/products.js +15 -3
  58. package/dist/products.js.map +1 -1
  59. package/dist/purchaseRules.d.ts +74 -0
  60. package/dist/purchaseRules.d.ts.map +1 -0
  61. package/dist/purchaseRules.js +73 -0
  62. package/dist/purchaseRules.js.map +1 -0
  63. package/dist/queries.d.ts +15 -13
  64. package/dist/queries.d.ts.map +1 -1
  65. package/dist/queries.js +43 -7
  66. package/dist/queries.js.map +1 -1
  67. package/dist/react/ShopifyProvider.d.ts +167 -25
  68. package/dist/react/ShopifyProvider.d.ts.map +1 -1
  69. package/dist/react/ShopifyProvider.js +352 -207
  70. package/dist/react/ShopifyProvider.js.map +1 -1
  71. package/dist/react/appForeground.d.ts +6 -0
  72. package/dist/react/appForeground.d.ts.map +1 -0
  73. package/dist/react/appForeground.js +19 -0
  74. package/dist/react/appForeground.js.map +1 -0
  75. package/dist/react/appForeground.native.d.ts +2 -0
  76. package/dist/react/appForeground.native.d.ts.map +1 -0
  77. package/dist/react/appForeground.native.js +23 -0
  78. package/dist/react/appForeground.native.js.map +1 -0
  79. package/dist/react/index.d.ts +7 -1
  80. package/dist/react/index.d.ts.map +1 -1
  81. package/dist/react/index.js +23 -1
  82. package/dist/react/index.js.map +1 -1
  83. package/dist/react/tileCreditClient.d.ts +4 -0
  84. package/dist/react/tileCreditClient.d.ts.map +1 -0
  85. package/dist/react/tileCreditClient.js +27 -0
  86. package/dist/react/tileCreditClient.js.map +1 -0
  87. package/dist/react/useAppDiscountCode.d.ts +19 -0
  88. package/dist/react/useAppDiscountCode.d.ts.map +1 -0
  89. package/dist/react/useAppDiscountCode.js +44 -0
  90. package/dist/react/useAppDiscountCode.js.map +1 -0
  91. package/dist/react/useCartStoreCredit.d.ts +25 -0
  92. package/dist/react/useCartStoreCredit.d.ts.map +1 -0
  93. package/dist/react/useCartStoreCredit.js +292 -0
  94. package/dist/react/useCartStoreCredit.js.map +1 -0
  95. package/dist/react/useOrders.d.ts +104 -0
  96. package/dist/react/useOrders.d.ts.map +1 -0
  97. package/dist/react/useOrders.js +287 -0
  98. package/dist/react/useOrders.js.map +1 -0
  99. package/dist/react/useProduct.d.ts.map +1 -1
  100. package/dist/react/useProduct.js +1 -9
  101. package/dist/react/useProduct.js.map +1 -1
  102. package/dist/react/useProductPage.d.ts +148 -0
  103. package/dist/react/useProductPage.d.ts.map +1 -0
  104. package/dist/react/useProductPage.js +178 -0
  105. package/dist/react/useProductPage.js.map +1 -0
  106. package/dist/react/useProductRecommendations.d.ts +27 -0
  107. package/dist/react/useProductRecommendations.d.ts.map +1 -0
  108. package/dist/react/useProductRecommendations.js +76 -0
  109. package/dist/react/useProductRecommendations.js.map +1 -0
  110. package/dist/react/useStoreCredit.d.ts +58 -0
  111. package/dist/react/useStoreCredit.d.ts.map +1 -0
  112. package/dist/react/useStoreCredit.js +209 -0
  113. package/dist/react/useStoreCredit.js.map +1 -0
  114. package/dist/react/useTileCredit.d.ts +4 -3
  115. package/dist/react/useTileCredit.d.ts.map +1 -1
  116. package/dist/react/useTileCredit.js +63 -55
  117. package/dist/react/useTileCredit.js.map +1 -1
  118. package/dist/shopify.d.ts.map +1 -1
  119. package/dist/shopify.js +2 -0
  120. package/dist/shopify.js.map +1 -1
  121. package/dist/storedList.d.ts +27 -0
  122. package/dist/storedList.d.ts.map +1 -0
  123. package/dist/storedList.js +152 -0
  124. package/dist/storedList.js.map +1 -0
  125. package/dist/tileCredit.d.ts +25 -10
  126. package/dist/tileCredit.d.ts.map +1 -1
  127. package/dist/tileCredit.js +97 -45
  128. package/dist/tileCredit.js.map +1 -1
  129. package/dist/types.d.ts +407 -34
  130. package/dist/types.d.ts.map +1 -1
  131. package/dist/types.js +1 -2
  132. package/dist/types.js.map +1 -1
  133. package/dist/variants.d.ts.map +1 -1
  134. package/dist/variants.js +4 -3
  135. package/dist/variants.js.map +1 -1
  136. package/dist/waitlist.d.ts +4 -0
  137. package/dist/waitlist.d.ts.map +1 -0
  138. package/dist/waitlist.js +213 -0
  139. package/dist/waitlist.js.map +1 -0
  140. package/dist/wishlist.d.ts.map +1 -1
  141. package/dist/wishlist.js +70 -47
  142. package/dist/wishlist.js.map +1 -1
  143. package/package.json +2 -2
package/README.md CHANGED
@@ -31,10 +31,11 @@ const cart = await shopify.cart.create({ lines: [{ merchandiseId: 'gid://…', q
31
31
  |---|---|
32
32
  | `shopify.products` | `list`, `byHandle`, `byId`, `search`, `recommended` |
33
33
  | `shopify.collections` | `list`, `byHandle`, `products` |
34
- | `shopify.cart` | `create`, `get`, `addLines`, `updateLines`, `removeLines`, `applyDiscountCodes`, `setBuyerIdentity`, `updateNote` |
34
+ | `shopify.cart` | `create`, `get`, `addLines`, `updateLines`, `removeLines`, `applyDiscountCodes`, `setBuyerIdentity`, `updateNote`, `addGiftCardCodes`, `applyGiftCardCodes` (replaces), `removeGiftCardCodes` |
35
35
  | `shopify.customer` | `signup`, `login`, `logout`, `profile`, `updateProfile`, `recoverPassword`, `orders`, `orderById` |
36
36
  | `shopify.blogs` | `list`, `byHandle`, `articles`, `articleByHandle` |
37
37
  | `shopify.wishlist` | `init`, `add`, `remove`, `toggle`, `has`, `list`, `count`, `clear`, `refresh`, `onChange` |
38
+ | `shopify.waitlist` | `init`, `add`, `remove`, `has`, `list`, `count`, `clear`, `refresh`, `onChange` |
38
39
  | `shopify.alerts` | `message`, `setMessages`, `patchMessages`, `setPolicy` — see [Alerts & Toasts](#alerts--toasts) |
39
40
 
40
41
  ### Cart note
@@ -63,12 +64,43 @@ const { cart, updateNote } = useCart();
63
64
 
64
65
  The hook's `updateNote` is **not** debounced — a note is typed and then committed, so write it on blur or a Save, not per keystroke. It is serialized against the line writes (a note landing mid-`addLine` would be applied to a cart the provider is about to replace, and would vanish), no-ops when there is no cart yet, and emits no alert event.
65
66
 
67
+ ### Gift cards on the cart
68
+
69
+ `useCart().addGiftCardCodes(codes)` adds gift cards and keeps the ones already on the cart (`cartGiftCardCodesAdd`); `removeGiftCards(appliedGiftCardIds)` takes off only the cards named (by `AppliedGiftCard.id`, not the code). Both wait in the cart's write queue like every other write, never create a cart (null when there is none), and resolve the new cart.
70
+
71
+ - **A country, only when missing.** Shopify takes a gift card only on a cart with `buyerIdentity.countryCode`. A cart without one gets `config.country` (else the shop's) first; `cartBuyerIdentityUpdate` replaces the identity, so the cart's email is sent again and, when signed in, the shopper's token keeps the cart theirs. A cart with a country is left alone.
72
+ - **A code Shopify skips is an error.** Shopify can answer `cartGiftCardCodesAdd` without applying a code and without any error (checked 2026-10-05 on the Storefront API 2026-07). `addGiftCardCodes` looks for each code's last characters on the cart and throws a `ShopifyError` (`code: 'GIFT_CARD_NOT_APPLIED'`) when one is missing.
73
+ - `shopify.cart.applyGiftCardCodes` is `cartGiftCardCodesUpdate`: it **replaces** every card on the cart. To add one, use `addGiftCardCodes`. Every Storefront version Shopify still serves has `cartGiftCardCodesAdd` (an older version is answered as the oldest supported one, 2025-10 today).
74
+ - `giftCardsEndingIn(cart, endings)` finds cards by a code or its last four; `giftCardCodesNotOnCart(cart, codes)` lists the codes Shopify skipped.
75
+
76
+ Tested in `test/cart-gift-cards.test.mjs` (8 checks).
77
+
78
+ ### The app's own discount code
79
+
80
+ A code the store keeps for orders from the app (an app-only discount), set in the app's settings
81
+ rather than typed by the shopper (0.10, SDK move 6):
82
+
83
+ ```tsx
84
+ useAppDiscountCode(settings.cart.appDiscountCode, { onError: (error) => report(error) });
85
+ ```
86
+
87
+ - Put on the cart once per cart and code, keeping the codes already on it (`applyDiscountCodes`
88
+ replaces the whole set).
89
+ - A code already on the cart, in any letter case, is left alone. Spaces around it are ignored. An
90
+ empty code, or no cart yet, does nothing.
91
+ - A write that fails goes to `onError`, and that code isn't tried again on that cart while the hook
92
+ stays mounted.
93
+ - It runs only while the screen calling it is mounted (amore-v2 calls it from the Cart).
94
+ - Tested in `test/app-discount-code.test.mjs` (6 checks).
95
+
66
96
  ### Wishlist — local-storage backed
67
97
 
68
- Stores a lightweight snapshot per product to whatever storage you give it (browser: `localStorage` by default; RN: pass AsyncStorage; server: pass any in-memory shim). Hydrates the full `Product` via a batched Storefront `nodes(ids:)` query on init and on demand, chunking arbitrarily large lists.
98
+ Stores each saved product with its entry, to whatever storage you give it (browser: `localStorage` by default; RN: pass AsyncStorage; server: pass any in-memory shim), so the list draws **offline**: `init()` shows what was stored at once, then refreshes the products with a batched Storefront `nodes(ids:)` query (chunked for any size). A refresh that fails keeps the stored products. What is kept is everything a card and a product page's instant view use: images past the first and the media list are not (`hasVideo` is), and details stop at about 1 MB per list, newest first, because Android's AsyncStorage can't read back a value over about 2 MB. Older entries past that keep their id and handle and load online. Stored products also seed the product store, so a saved product's page opens with its instant view offline.
69
99
 
70
100
  Deleted products (Storefront returns `null`) are pruned automatically. Pass `refresh({ keepDeleted: true })` to keep them with `product: null` for a "no longer available" UI.
71
101
 
102
+ **Moving from Apptile's engine, or an older key.** Pass the key the old app used as `storageKey` (Apptile: `<apptile app id>_WishlistProducts`, entries `{ id, handle }` with a numeric id) and any other earlier key as `migrateFrom` (e.g. `tile:shopify:wishlist:v1`). Both shapes are read; entries are written as a superset of all of them (`productId`, `basic`, `addedAt` as sdk-shopify ≤0.8 reads them, plus `id`, `handle` and the stored `product`), so an older bundle after an OTA rollback still reads the list. Each `migrateFrom` key is merged once (recorded under `<storageKey>:merged`) and left as it was, so an un-starred product never comes back. A stored value that isn't a list is copied to `<storageKey>:unreadable` before anything replaces it, and a list that fails to read is never written that session. On `ShopifyProvider`: `wishlistStorageKey` and `wishlistMigrateFrom`. The provider loads the wishlist before the cart, so a cart that can't load (offline) doesn't take it down.
103
+
72
104
  ```ts
73
105
  await shopify.wishlist.init(); // rehydrates from storage, background-refreshes from API
74
106
  await shopify.wishlist.add(product);
@@ -77,6 +109,21 @@ await shopify.wishlist.toggle(product);
77
109
  shopify.wishlist.onChange(items => …); // subscribe
78
110
  ```
79
111
 
112
+ ### Waitlist — sizes a shopper waits on, offline like the wishlist
113
+
114
+ Keyed by **variant** (you wait on a size or colour; pre-order is per variant), newest first, stored with the same rules as the wishlist (one shared module, `storedList`): each entry carries the variant as last fetched (`variants.byIds`: stock, price, pre-order plan, and its product's title, handle, image and `hasVideo`), so the list draws offline, and a refresh that fails keeps it. A variant the store no longer has is kept as `variant: null` for the screen to leave out, in case it comes back. Joining a variant already on the list moves it to the front, dated now.
115
+
116
+ ```ts
117
+ await shopify.waitlist.init({ storage: AsyncStorage });
118
+ await shopify.waitlist.add({ variantId, productId, productHandle }); // or `variant` when you have it
119
+ shopify.waitlist.has(variantId);
120
+ await shopify.waitlist.refresh(); // stock, price, pre-order plan
121
+ ```
122
+
123
+ `<ShopifyProvider>` loads it before the cart, like the wishlist, and `useWaitlist()` gives `{ items, ids, count, has, add, remove, refresh }`. `add` emits `waitlist:add` ("Added to waitlist", Settings panel `waitlist.added`); `remove` emits `waitlist:remove` with no message, so no toast. Moving from Apptile's engine: `waitlistStorageKey` = `<apptile app id>_WaitlistProducts` (entries `{ id, handle }`: the numeric **variant** id and the product's handle), and `waitlistMigrateFrom` for any earlier key (`{ variantId, productId, addedAt }` with an ISO date reads too). Image size: `imageTransforms.waitlist`.
124
+
125
+ The waitlist records interest on the device. A back-in-stock push is the app's to request: the platform's automation selects on an analytics event that needs the signed-in customer.
126
+
80
127
  ## Optional React helper
81
128
 
82
129
  ```ts
@@ -198,22 +245,28 @@ the background and updates in place (`refreshing` is true meanwhile; no spinner
198
245
  younger than a minute (`REVALIDATE_AFTER_MS`) isn't re-read. `refresh()` always goes to the network.
199
246
 
200
247
  ```tsx
201
- // Product page: opens with title, image and price from whichever grid or search showed it.
248
+ // Product page: opens with title, image, price, the variant picker and the description from
249
+ // whichever grid or search showed it.
202
250
  const { preview, product, level, loading, refreshing, notFound, refresh } = useProduct(handle);
203
- // preview: the base keys, render now. product: full (variants, media), render the picker when set.
251
+ // preview: the base keys, render now (variants included). product: full (adds the gallery media).
204
252
 
205
253
  // Listing page / search page: the last first page paints immediately, even after a cold start.
206
254
  const feed = useCollectionProducts({ handle, pageSize: 12 });
207
255
  const results = useSearch(term, { debounceMs: 300 }); // results.query is the debounced term
208
256
  ```
209
257
 
210
- - **Every product read records its base keys** (`PRODUCT_BASE_KEYS`: `id`, `handle`, `title`,
211
- `featuredImage`, `priceRange`, `compareAtPriceRange`): collections, search, recommendations, lists,
212
- `byIds`, the wishlist. `byHandle`/`byId` record the full product. So a product tapped anywhere
213
- opens instantly, and a product page seen before opens complete.
214
- - **Kept on the device** across cold starts, one small key per product, read only when something
215
- asks (nothing is loaded in bulk at launch). Caps: 2,500 base entries, 30 full products, 20 first
216
- pages; anything older than 7 days is dropped. Keys carry a schema version and the store, market,
258
+ - **Every product read records its base keys** (`PRODUCT_BASE_KEYS`): `id`, `handle`, `title`,
259
+ `featuredImage`, `priceRange`, `compareAtPriceRange`, and since 0.9 also `options`, `variants`,
260
+ `description`, `descriptionHtml`, `availableForSale`, `totalInventory`, `tags`, `vendor` and
261
+ `productType`. Collections, search, recommendations, lists, `byIds` and the wishlist already fetch
262
+ all of these, so keeping them costs no request. `byHandle`/`byId` record the full product (which
263
+ adds the gallery's media). So a product tapped anywhere opens with its picker, stock state and
264
+ description in the first frame, and a product page seen before opens complete.
265
+ - **Kept on the device** across cold starts, one key per product, read only when something asks
266
+ (nothing is loaded in bulk at launch). Caps: 2,500 base entries (3-10 KB each with variants and
267
+ description, so roughly 10-25 MB at the cap), 30 full products, 20 first pages; anything older
268
+ than 7 days is dropped. A schema change bumps the key version (now `v3`, since each variant
269
+ carries `sellingPlan`), and the old versions' keys are cleared from the device once, after launch. Keys carry a schema version and the store, market,
217
270
  API version and metafields, so nothing mismatched is ever shown. Catalogue data only: never cart,
218
271
  customer, orders or wallet.
219
272
  - **Storage:** on iOS/Android, **MMKV 3** (synchronous, read in microseconds inside a render). List
@@ -226,9 +279,131 @@ const results = useSearch(term, { debounceMs: 300 }); // results.query is the de
226
279
  - The provider sets the config on its first render, so these reads start at once instead of waiting
227
280
  for its own startup (cart, wishlist, customer).
228
281
 
282
+ ## Product page: `useProductPage`
283
+
284
+ Everything a product page decides, in one hook, on `useProduct`'s cache-first product. The screen
285
+ keeps only what is the app's: layout, navigation, its own rules (passed as options) and side effects.
286
+
287
+ ```tsx
288
+ const page = useProductPage(handle, {
289
+ imageTransform: { maxWidth: 1080 },
290
+ playVideo: params.playVideo === '1',
291
+ isBlocked: (p) => p.tags.includes('SEARCH-BLOCKED'), // e.g. live in an auction
292
+ parseDescription: afterTransition, // false to wait; or your own parser
293
+ onView: (p) => analytics.track('productView', params(p)), // once per product, full product
294
+ onAdded: () => setSheetVisible(true),
295
+ });
296
+
297
+ page.shown; // product ?? preview: render now (variants included since 0.9)
298
+ page.status; // 'loading' | 'unknown' | 'available' | 'unavailable' | 'blocked'
299
+ page.selection.optionStates; // each option, each value: selected, available, units left, its variant
300
+ page.selection.setOption(name, value); page.selection.selectVariant(id);
301
+ page.selection.variant; page.selection.label; page.selection.price; // { price, compareAtPrice, onSale }
302
+ page.selection.lowStock;
303
+ page.selection.variant.sellingPlan; // its pre-order plan { id, name }, or null (see below)
304
+ page.cart.add(); // stock ceiling first; never throws; { ok, reason, message }
305
+ page.cart.adding; page.cart.inCart; page.cart.canAddMore;
306
+ page.media.items; page.media.initialIndex; page.media.previewImageUrl; page.media.firstImageUrl;
307
+ // each item: kind, posterUrl, videoUrl, alt, width/height (original px)
308
+ page.description.blocks; // paragraphs and bullets with bold runs, from descriptionHtml
309
+ page.favorite.isFavorite; page.favorite.toggle();
310
+ page.shareUrl;
311
+ page.recommendations.products; // "You may also like", when asked: { products, loading, error }
312
+ ```
313
+
314
+ The same list on its own, for any page (a cart's "you may also like"):
315
+
316
+ ```tsx
317
+ const { products, loading } = useProductRecommendations(product.id, { limit: 8, imageTransform: { maxWidth: 660 } });
318
+ ```
319
+
320
+ Read once per product and kept for the session, so a reopened product has them on its first render.
321
+ Every product read lands in the product store with its variants, so a tapped recommendation opens like
322
+ a grid's card.
323
+
324
+ **Pre-order plans.** Every product read (`products.*`, collections, search, recommendations, `byIds`)
325
+ asks for each variant's first selling-plan allocation, `variant.sellingPlan`: `{ id, name }` when the
326
+ store enrolled that variant in a plan (a pre-order), else `null` (about 0.1 KB per variant before
327
+ gzip; 2-4% more on the wire for a page of cards). It is the variant's own allocation, so it names the
328
+ plan to add with even when the product is in several groups. When a size is pre-ordered is
329
+ `preorderPlanFor` (below; until SDK move 6 each app had its own rule). A pre-order add is `page.cart.add({ sellingPlanId: variant.sellingPlan.id })`, which a sold-out but
330
+ still-for-sale size passes with no stock ceiling (`stockCeiling` is null there). A cart line's
331
+ `merchandise` has no `sellingPlan`: the line's own `sellingPlanId` says how it was bought.
332
+
333
+ **A cart line on a plan** carries `line.sellingPlan`: `{ id, name, checkoutCharge, remainingBalance }`,
334
+ or null for a line bought outright. The two amounts are for the whole line: what checkout takes now
335
+ and what is charged later (for a pre-authorize plan, `$0.00` now and the full price when it ships).
336
+ Shopify answers them per unit, so the SDK multiplies them by the line's quantity (checked 2026-10-05:
337
+ a line of 2 at $250 said $0 and $250). An amount Shopify leaves out is `null`: unknown, not zero.
338
+ The cart's own `cost.checkoutChargeAmount` is what checkout takes now for the whole cart, while
339
+ `subtotalAmount` and `totalAmount` still count a pre-order line at its full price.
340
+
341
+ ```tsx
342
+ const plan = line.sellingPlan;
343
+ plan && `Pre-authorized at ${formatMoney(plan.checkoutCharge)}, pay ${formatMoney(plan.remainingBalance)} when it ships`;
344
+ ```
345
+
346
+ **Customise any rule** with an option; every default is the production behaviour:
347
+
348
+ | Option | Default |
349
+ | --- | --- |
350
+ | `optionOrder` | Color, Colour, Size, then the store's order |
351
+ | `includeOption(option)` | all but Shopify's "Default Title" placeholder |
352
+ | `initialSelection(variants)` / `initialVariantId` | the first purchasable variant |
353
+ | `isUnavailable(variant)` | missing, not for sale, or 0 left (the waitlist case) |
354
+ | `lowStockThreshold` | 10 units across the product; `null` turns it off |
355
+ | `isBlocked(product)` | none |
356
+ | `maxQuantity(variant)` | its stock (`stockCeiling`); `null` for no ceiling |
357
+ | `quantity`, `attributes`, `sellingPlanId` | 1, none, none (each `add()` can override) |
358
+ | `source` | the app's own. Where the page's adds came from (`{ type: 'live' \| 'replay', showId }`) for the provider's `attribution`; never sent to Shopify. Each `add({ source })` can override. Needs 0.9.1's attribution (SDK move 6) |
359
+ | `recommendations` | none read. `true` or `{ enabled, limit, imageTransform, exclude }` reads Shopify's related products (`productRecommendations`), without the product itself or anything `isBlocked` marks, at most 10. Below the fold: pass `enabled: false` until the page has settled |
360
+ | `parseDescription` | `true`; `false` to wait, or `(html) => blocks` |
361
+ | `onView`, `onAdded`, `onRefused`, `onError` | none |
362
+ | `separator`, `storeDomain`, `resetKey` | `" / "`, the configured store, the handle |
363
+
364
+ **Pre-order and buy: one rule (0.10, SDK move 6).** What a shopper can do with a size, the same on a
365
+ product page and a waitlist card (from main, pure):
366
+
367
+ ```tsx
368
+ const blocked = page.status === 'blocked'; // e.g. an auction (isBlocked)
369
+ const plan = preorderPlanFor(page.selection.variant, { blocked }); // → page.cart.add({ sellingPlanId: plan.id })
370
+ const mode = purchaseModeFor({ status: page.status, variant: page.selection.variant, canAddMore: page.cart.canAddMore, heldInOtherCarts });
371
+ // 'blocked' | 'preorder' | 'soldOut' | 'heldInOtherCarts' | 'allInCart' | 'buy'
372
+ const action = waitlistActionFor(item.variant, cart, { blocked: isAuctioned(item.variant.product) });
373
+ // 'blocked' | 'preorder' | 'addToCart' | 'inCart' | 'waiting'
374
+ ```
375
+
376
+ - **A pre-order** is a size sold out (Shopify's count known and 0 or less), still for sale, with a plan
377
+ of its own (`variant.sellingPlan`), on a product that isn't blocked.
378
+ - **Stock not tracked** (`quantityAvailable` null) isn't sold out: that size is bought, never
379
+ pre-ordered. Decided 2026-10-06, Head of Engineering: "Add to Cart" (untracked means always
380
+ available).
381
+ - **A blocked product** (an auction) can't be pre-ordered or bought anywhere: the page's mode and a
382
+ waitlist card's action are both `'blocked'`. Decided 2026-10-06, Head of Engineering: "Auction notice
383
+ wins".
384
+ - The page's mode, the first that applies: blocked, pre-order, sold out, held in other carts (a
385
+ reservation service's refusal), every unit already in this cart, buy.
386
+ - A waitlist card: blocked, pre-order, add to cart (back in stock with a unit to spare), in cart, else
387
+ waiting. `StandaloneVariant.product.tags` (0.10) is there for the card's `blocked` rule.
388
+ - Tested in `test/purchase-rules.test.mjs` (16 checks).
389
+
390
+ **Use only the part you need.** `useVariantSelection(product, options)` is the selection, states,
391
+ price and stock for any surface that sells a variant (a buy sheet, a quick add); `useAddToCart(variant,
392
+ options)` is the add with the ceiling. The pure helpers behind them are exported too: `selectableOptions`,
393
+ `findVariant`, `initialSelection`, `optionStates`, `selectionLabel`, `variantPrice`, `isLowStock`,
394
+ `stockCeiling`, `quantityInCart`, `initialMediaIndex`, `firstImageUrl`, `sizedImageUrl`,
395
+ `parseDescriptionHtml`, `productShareUrl`.
396
+
397
+ **The stock ceiling.** Shopify answers 200 to an add past the stock level and clamps it silently, so
398
+ the shopper would be told it worked. `addLine({ …, maxQuantity })` checks before the write, and
399
+ before any `cartGuard` (so Cart Hold never reserves for an add that is then refused). A refusal is
400
+ `reason: 'stock'` with the new `cart.noMoreStock` alert ("No more stock available", a Settings panel
401
+ field at `settings.alerts.cart.noMoreStock`) and a `cart:stockLimit` event, so the app's toast shows
402
+ it like every other alert. `maxQuantity` is never sent to Shopify.
403
+
229
404
  ## Alerts & Toasts
230
405
 
231
- The editor's Settings panel configures the copy for 13 shopper-facing alerts. The
406
+ The editor's Settings panel configures the copy for 14 shopper-facing alerts. The
232
407
  SDK never renders anything — it resolves the right string and hands it to you on
233
408
  an event, so a screen can toast without keeping its own copy table or mapping
234
409
  Shopify error shapes to sentences.
@@ -250,13 +425,15 @@ Every event carries the resolved copy, so that one-liner is the whole integratio
250
425
  | `cart:add` | `cart.added` | Cart |
251
426
  | `cart:remove` | `cart.removed` | Cart |
252
427
  | `cart:limitExceeded` | `cart.limitExceeded` | Cart |
428
+ | `cart:stockLimit` | `cart.noMoreStock` | Cart |
253
429
  | `cart:outOfStock` | `cart.outOfStock` | Checkout |
254
430
  | `wishlist:add` / `wishlist:remove` | `wishlist.added` / `wishlist.removed` | Wishlist |
431
+ | `waitlist:add` / `waitlist:remove` | `waitlist.added` / none (no toast) | Waitlist |
255
432
  | `auth:loginSuccess` / `auth:loginFailed` | `auth.loginSuccess` / `auth.loginFailed` | Login |
256
433
  | `auth:logout` / `auth:recoverSent` | `auth.loggedOut` / `auth.resetLinkSent` | Login |
257
434
  | `checkout:orderPlaced` / `checkout:paymentFailed` | `checkout.orderPlaced` / `checkout.paymentFailed` | Checkout |
258
435
 
259
- `cart:update` and `cart:buyerIdentity` are state signals and carry no message.
436
+ `cart:update`, `cart:buyerIdentity` and `auth:sessionExpired` are state signals and carry no message.
260
437
  `wishlist.empty` has no event — read it directly for a placeholder:
261
438
 
262
439
  ```tsx
@@ -269,6 +446,51 @@ Resolution order per key: `messages` prop → `config.messages` → `translate(k
269
446
  rendering blank. `translate` is called with the i18n key where one exists
270
447
  (`cart.added` → `toast.added_to_cart`), otherwise the message key itself.
271
448
 
449
+ ### What changed, on the event, and `useShopifyEvents` (0.10, SDK move 7)
450
+
451
+ The cart, wishlist and sign-in events say what changed, so a listener can act on the event itself.
452
+ Until 0.10 they carried only `type`, `severity` and the copy, and React's `useCart().cart` hadn't
453
+ caught up when they arrived; every app's analytics waited 50 ms and diffed the cart to find the
454
+ line.
455
+
456
+ | Event | Also carries |
457
+ | --- | --- |
458
+ | `cart:add`, `cart:update`, `cart:remove` | `cart`: the cart the write returned. `changedLines: CartLineChange[]`: each line the write changed, once |
459
+ | `wishlist:add`, `wishlist:remove` | `productId` |
460
+ | `auth:loginSuccess` | `customer` (null while the profile hasn't loaded; `useCustomer().customer` has it once it does) and `sessionKind` |
461
+
462
+ ```ts
463
+ interface CartLineChange {
464
+ line: CartLine; // the line after the write; for a line the write took out, the line as it was
465
+ quantityChange: number; // units added (above 0) or taken away (below 0); 0 when only its attributes changed
466
+ }
467
+ ```
468
+
469
+ - An add onto a line already in the cart reports that line with its new quantity, and
470
+ `quantityChange` is what was added. An `addLines` that landed several lines lists each line once:
471
+ two inputs that landed on one line (same variant and attributes) are counted together.
472
+ - `cart:update` to 0 and `cart:remove` report the line as it was.
473
+ - Failures carry their `error` as before, and `checkout:orderPlaced` still carries its details as
474
+ `error`.
475
+
476
+ **`useShopifyEvents(listener)`** hears every event from anywhere inside the provider: the same events
477
+ `onEvent` gets, told right after it, in order. It needs no `onEvent`. The listener is read when an
478
+ event fires, so a new function each render is fine; it starts once the component has mounted and
479
+ stops when it unmounts. A listener that throws is warned about and costs nothing else.
480
+
481
+ ```tsx
482
+ function CartAnalytics() {
483
+ const { track } = useAnalytics();
484
+ useShopifyEvents((event) => {
485
+ for (const sent of cartAndWishlistEvents(event)) track(sent.name, sent.properties); // @tiledev/sdk-analytics-core
486
+ });
487
+ return null;
488
+ }
489
+ ```
490
+
491
+ This replaces the one-hop event bus each app kept so a listener inside the provider could hear
492
+ `onEvent`, which is written above it.
493
+
272
494
  ### Reading the panel from the Live Layer
273
495
 
274
496
  The editor publishes these fields into the app's Live Layer tree. The SDK owns
@@ -316,21 +538,345 @@ unsellable line still throws — `cart:outOfStock` fires on the way out.
316
538
  > **Breaking from 0.1.x:** `addLine` used to return `boolean`. An object is always
317
539
  > truthy, so `if (await addLine(...))` no longer detects a refusal — read `.ok`.
318
540
 
319
- ### Customer session
541
+ ### Cart guard (`cartGuard`)
542
+
543
+ A `CartLineGuard` on the provider runs around every cart write: `beforeAdd` / `beforeIncrease` can
544
+ change or veto it, `onLanded` / `onReleased` hear what happened. Cart Hold
545
+ (`@tiledev/sdk-apptile-cart-hold`) is one. The provider keeps two promises to it (0.9):
546
+
547
+ - **Every unit a guard approves either lands or comes back.** An add the line limit then refuses, an
548
+ add Shopify refuses, and an add whose cart can't be created all fire `onReleased` with
549
+ `reason: 'rejected'`. A rejected add carries `input`, as the guard approved it (with any attributes
550
+ it added: Cart Hold's receipt); a rejected increase carries the `line` it was to grow. Before 0.9 the
551
+ line-limit and cart-creation paths released nothing.
552
+ - **Attributes aren't lost to a guard.** A guard that approves an increase without returning
553
+ attributes keeps the caller's; and since attributes replace a line's set, an update keeps the line's
554
+ private (`_`-prefixed) attributes the caller didn't mention, so editing a note can't wipe a hold.
555
+
556
+ Two more things a guard can rely on:
557
+
558
+ - **The quantity it returns is the one added.** `beforeAdd` may lower it: Cart Hold approves only the
559
+ units still free. `onLanded` and a `rejected` release carry that quantity too.
560
+ - **A quiet add.** `addLines(inputs, { quiet: true })` is for a caller that reports the outcome
561
+ itself (Buy again's one summary). Every `beforeAdd(input, options)` gets `{ quiet: true }`, so the
562
+ guard says nothing about a refusal (Cart Hold shows no toast) but still decides as usual. And the
563
+ `cart:outOfStock` the line-by-line retry raises for the lines Shopify refused is not emitted. That is
564
+ the only out-of-stock alert `addLines` raises, so a quiet `addLines` raises none. Still emitted:
565
+ `cart:add` when something lands (analytics reads it too) and `cart:limitExceeded` when the line
566
+ limit refuses the whole batch. `addLine` and `beforeIncrease` have no quiet.
567
+
568
+ `test/cart-guard.test.mjs` covers all of it (10 checks).
320
569
 
321
- `useCustomer()` owns the token so the Login alerts have somewhere to originate. It
322
- persists to the same storage as the cart and restores on mount; an expired token
323
- is dropped silently, because reopening the app is not a failed login.
570
+ ### Customer session: two ways to sign in
571
+
572
+ `useCustomer()` owns the session, so the Login alerts have somewhere to originate. Every app gets
573
+ both sign-ins, and `auth.method` says which one it offers now:
574
+
575
+ | `auth.method` | Login | Shopify feature | Calls |
576
+ | --- | --- | --- | --- |
577
+ | `password` (the default) | Email and password | Classic customer accounts, Storefront `customerAccessToken` | `login`, `signup`, `recoverPassword` |
578
+ | `shopify` | Shopify's web sign-in (passwordless) | New customer accounts, Customer Account API, OAuth 2 + PKCE | `signIn` (system sheet), `startSignIn` (in-app web view) |
579
+
580
+ `method` can change while the app runs, e.g. from a Live Layer field: `password` while a build is
581
+ in App Store review (the reviewer needs a demo email and password; Shopify's sign-in mails a
582
+ one-time code to an inbox they can't read), `shopify` for shoppers. A shopper already signed in
583
+ stays signed in when it flips; `sessionKind` says which kind of session they have.
584
+
585
+ ```tsx
586
+ import * as Crypto from 'expo-crypto';
587
+ import * as SecureStore from 'expo-secure-store';
588
+ import * as WebBrowser from 'expo-web-browser';
589
+
590
+ <ShopifyProvider
591
+ config={config}
592
+ storage={AsyncStorage}
593
+ auth={{
594
+ method: reviewMode ? 'password' : 'shopify',
595
+ customerAccount: { shopId, clientId }, // a Public (mobile) Customer Account API client
596
+ secureStorage: { // tokens go to the keychain, never AsyncStorage
597
+ getItem: SecureStore.getItemAsync,
598
+ setItem: SecureStore.setItemAsync,
599
+ removeItem: SecureStore.deleteItemAsync,
600
+ },
601
+ openAuthSession: (url, redirectUri) =>
602
+ WebBrowser.openAuthSessionAsync(url, redirectUri, { preferEphemeralSession: true }),
603
+ random: Crypto.getRandomBytes, // PKCE needs secure random bytes; Hermes has none
604
+ }}
605
+ storeCredit={{ source: 'shopify' }} // or { source: 'tile' }
606
+ >
607
+ ```
324
608
 
325
609
  ```tsx
326
- const { loggedIn, customer, login, logout, recoverPassword, restoring } = useCustomer();
327
- // `login` resolves false on bad credentials (and emits auth:loginFailed).
328
- // Anything else — network, store down — throws.
610
+ const { method, loggedIn, sessionKind, customer, restoring, login, signIn, logout, getAccessToken, renewAccessToken, updateProfile } = useCustomer();
611
+
612
+ // password: false on bad credentials (auth:loginFailed fires). Offline throws.
613
+ await login(email, password);
614
+
615
+ // shopify, system sheet: false when the shopper backs out (no event) or Shopify refuses
616
+ // (auth:loginFailed). Offline throws.
617
+ await signIn();
618
+
619
+ // shopify, the app's own web view: load attempt.url, stop the web view at the redirect, finish.
620
+ const attempt = startSignIn();
621
+ <WebView source={{ uri: attempt.url }} incognito
622
+ originWhitelist={['https://*', 'shop.<shopId>.app://*']}
623
+ onShouldStartLoadWithRequest={(r) => attempt.isCallback(r.url) ? (void attempt.finish(r.url), false) : true} />
624
+
625
+ // Either kind: a usable token for checkout or another SDK, refreshed when needed.
626
+ const token = await getAccessToken();
627
+
628
+ // A service just answered 401 with it: a token renewed now (Shopify sign-in: the session's one shared
629
+ // refresh; a password session can't renew, so null). Never signs the shopper out.
630
+ const renewed = await renewAccessToken();
631
+
632
+ // Either kind: change the name and email marketing consent. false on failure; never throws.
633
+ const saved = await updateProfile({ firstName, lastName, acceptsMarketing });
329
634
  ```
330
635
 
636
+ Working examples of each piece, typechecked against the Expo modules they use, are in
637
+ [`examples/auth`](examples/auth): `AppProviders.tsx`, `PasswordSignIn.tsx`,
638
+ `ShopifySignInSheet.tsx`, `ShopifySignInWebView.tsx`, and `AccountScreen.tsx`, which picks the
639
+ sign-in by `method`.
640
+
641
+ **Which surface for Shopify sign-in.** The system sheet is the default: Shopify's social sign-ins
642
+ work there (Google refuses embedded web views), and on iOS `preferEphemeralSession` means no
643
+ consent alert and no silent resume of another shopper. The in-app web view keeps the page inside
644
+ the app's design, and `incognito` stops it resuming the last shopper on Android, where Custom Tabs
645
+ share Chrome's cookies.
646
+
647
+ **The rules, the same for both sign-ins:**
648
+
649
+ - The shopper's own answer (a wrong password, a taken email, a refused sign-in) resolves `false`
650
+ and fires `auth:loginFailed`. Backing out of the sign-in page resolves `false` and fires nothing.
651
+ - The store being unreachable throws, and never ends a session: being offline is not being signed out.
652
+ - On app open a stored session is restored without a toast. Offline it stays signed in
653
+ (`loggedIn` true, `customer` null until `refresh()`).
654
+ - A session Shopify no longer accepts ends: silently on app open, and with `auth:sessionExpired`
655
+ (no message; route to sign-in if you like) while the app is in use.
656
+ - `logout()` clears the device first, then tells Shopify, and always resolves.
657
+ - Shopify sign-in: state, nonce and the PKCE verifier are checked; a redirect the app didn't start
658
+ is never exchanged. Refresh tokens rotate, so concurrent callers share one refresh, and a refresh
659
+ that lands after sign-out is discarded. A 401 refreshes once and retries.
660
+ - Password sign-in: a token with under 7 days left is renewed on app open (`customerAccessTokenRenew`).
661
+
662
+ Each rule is a test: `test/auth-password.test.mjs` (20 checks), `test/auth-shopify.test.mjs` (39,
663
+ including SHA-256 against node:crypto and RFC 7636's PKCE vector), `test/auth-provider.test.mjs`
664
+ (11: the review-mode switch, store credit and `updateProfile` through the provider), and
665
+ `test/auth-profile.test.mjs` (21: `updateProfile` for both kinds of session).
666
+
667
+ **Editing the profile.** `updateProfile({ firstName?, lastName?, acceptsMarketing? })` sends only
668
+ the fields that differ from `customer`. An unset name counts as `''`, and when nothing differs it
669
+ resolves `true` without a request. Each kind of session uses its own API:
670
+
671
+ | `sessionKind` | Names | Email marketing consent |
672
+ | --- | --- | --- |
673
+ | `password` | Storefront `customerUpdate` | the same call (`acceptsMarketing`) |
674
+ | `shopify` | Customer Account API `customerUpdate(input: { firstName, lastName })` | `customerEmailMarketingSubscribe` or `customerEmailMarketingUnsubscribe` |
675
+
676
+ - `true` once Shopify accepted every change. `customer` already shows the new values: for a
677
+ password session it is the customer `customerUpdate` returns; for a Shopify session the profile
678
+ is read again.
679
+ - `false` when signed out, when Shopify refuses a value (its `userErrors`), or when the store can't
680
+ be reached. It never throws. The reason goes to `console.warn('[sdk-shopify] profile update
681
+ failed', …)`, and no event fires, so the screen shows its own message. A session Shopify stops
682
+ accepting mid-save still ends with `auth:sessionExpired`.
683
+ - A Shopify session writes the names first, then the consent, and stops at the first refusal.
684
+ Whatever Shopify accepted before that is read back, so `customer` never shows a value Shopify
685
+ doesn't hold. If that read fails (offline straight after saving), `customer` shows the accepted
686
+ values.
687
+ - `CUSTOMER_ALREADY_SUBSCRIBED` counts as success: it is the state the shopper asked for, and it
688
+ only comes back when `customer` was out of date.
689
+ - Phone and email can't be changed here. The Customer Account API's `CustomerUpdateInput` has only
690
+ the two names, and an email change goes through Shopify's own verification.
691
+ - `customer.loading` is true while it runs.
692
+
693
+ **Storage keys.** The Shopify session uses production Amber's keychain keys (`auth.token`,
694
+ `auth.refreshToken`, `auth.expiresAt`, `auth.idToken`), so its signed-in shoppers stay signed in
695
+ after updating to a Tile build. The password session uses `auth.storefrontToken` and
696
+ `auth.storefrontExpiresAt`; a token stored by 0.8 (`shopify:customer-token:v1` in `storage`) moves
697
+ there on first read. Every key is one expo-secure-store accepts (letters, digits, `.`, `-`, `_`):
698
+ it throws on a `:`, which is why 0.8's key can't go to the keychain as it was. The tests' keychain
699
+ refuses the same keys. Without `secureStorage`, both go to `storage` (the web preview).
700
+
701
+ **Changed from 0.8:** `loggedIn` means a session exists (it was "a profile loaded"), and the
702
+ session is restored even when the shop fails to load.
703
+
704
+ ### Store credit
705
+
706
+ `storeCredit` on the provider picks the source per app; `useStoreCredit()` reads it either way:
707
+
708
+ ```tsx
709
+ const { source, available, balance, loading, error, refresh } = useStoreCredit();
710
+ {available && <Text>Store credit: {formatMoney(balance)}</Text>}
711
+ ```
712
+
713
+ | `storeCredit.source` | Reads | Works with |
714
+ | --- | --- | --- |
715
+ | `shopify` | Shopify's store credit (`storeCreditAccounts`, summed across currencies' accounts) | Shopify sign-in only: the Storefront API has no store credit, so `available` is false for a password session |
716
+ | `tile` | The Tile Credit wallet (`/public/me`) | Either sign-in |
717
+
718
+ `useStoreCreditHistory()` reads the lines behind the balance, from the same source, newest first, a
719
+ page at a time:
720
+
721
+ ```tsx
722
+ const { entries, loading, loadingMore, hasMore, error, loadMore, refresh } = useStoreCreditHistory(); // { pageSize?: 25 }
723
+ // entries: StoreCreditEntry[] — { id, kind, isCredit, amount, createdAt, expiresAt, note, orderName }
724
+ ```
725
+
726
+ | `storeCredit.source` | Reads | Pages by |
727
+ | --- | --- | --- |
728
+ | `shopify` | The Customer Account API's store-credit transactions (every account, merged by date) | Each account's own cursor |
729
+ | `tile` | Tile Credit's ledger (`/public/me/ledger`), with the balance's service, token and shop | The ledger's cursor |
730
+
731
+ Each line comes with a `kind` (`signupBonus`, `liveShowReward`, `orderReward`, `addedByStore`,
732
+ `removedByStore`, `spent`, `refunded`, `expired`, `added`, `removed`) so the app words it; `amount` is
733
+ never negative and `isCredit` says which way it went. `note` is the store's own words for a change made
734
+ by hand (Tile Credit only, and only when they read as a sentence); `orderName` is the order it came
735
+ from (`#1043`) when the source names it. A refresh starts the list over from the newest page and keeps
736
+ what is shown if it fails. Tested in `test/store-credit-history.test.mjs` (20 checks: each source's
737
+ lines and pages, and the hook through the provider).
738
+
739
+ ### Store credit on the cart: `useCartStoreCredit`
740
+
741
+ ```tsx
742
+ const { source, status, balance, applied, error, apply, remove, refresh } = useCartStoreCredit();
743
+ // status: 'hidden' | 'loading' | 'ready' | 'applying' | 'applied' | 'removing' | 'atCheckout' | 'error'
744
+ await apply(typedAmountToCents('$25.00')!); // true once the credit is on the cart; never rejects
745
+ await remove(); // takes off only the app's card
746
+ ```
747
+
748
+ | `storeCredit.source` | Apply | Remove |
749
+ | --- | --- | --- |
750
+ | `tile` | Mints a gift card for the amount (`/public/me/redeem`) and adds it to the cart (`addGiftCardCodes`) | The app's card comes off; other gift cards stay |
751
+ | `shopify` | Nothing: `status` is `atCheckout`, as only Shopify's checkout takes it | Nothing |
752
+
753
+ - **One Apply or Remove at a time**: a second call while one runs gets the running one's promise. Two redeems at once with different keys would mint two cards; the service's idempotency isn't atomic.
754
+ - **A new card on every Apply**, with a new idempotency key. The key is reused only to retry the same attempt (same shopper, same amount, right after it failed), so a retry after a lost answer gets back the card already minted. A new card disables the previous one, so an earlier card of the app's still on the cart comes off.
755
+ - **The app's card** is the one whose last characters match the card it minted, or after a restart the shopper's active card (`/public/me/gift-cards`). `applied` is what it takes off this cart (`presentmentAmountUsed`).
756
+ - **Read again** on mount, for each new shopper (another shopper's balance never shows, not for one render), when the app comes back to the foreground (`AppState` on a phone, the page's visibility on the web), after each Apply and Remove, and on `refresh()`.
757
+ - **No upper limit in the app**: an amount over the balance comes back `insufficient_balance`, outside the store's limits `validation`. `error` is a `TileCreditError`; the app words its `code`.
758
+ - `typedAmountToCents(text)` reads a typed amount: `1500`, `1,500`, `1500.5`, `$1,500.00` and a decimal comma (`12,50`); null for letters, a minus or two decimal points.
759
+
760
+ Tested in `test/cart-store-credit.test.mjs` (19 checks) and `test/tile-credit-client.test.mjs` (16 checks: the parser, the token, the provider's session).
761
+
762
+ ### Orders
763
+
764
+ `useOrders()` lists the signed-in shopper's orders and `useOrder(id)` reads one, with Buy again. They
765
+ work for either sign-in and answer in the same shapes (`OrderSummary`, `OrderDetails`):
766
+
767
+ ```tsx
768
+ const { orders, loading, loadingMore, error, hasMore, loadMore, refresh } = useOrders(); // { pageSize?: 25 }
769
+ const { order, loading, error, notFound, refresh, buyAgain } = useOrder(orderId); // an id from `orders`
770
+ const { added, skipped } = await buyAgain(); // lines, not units
771
+ await buyAgain({ skipVariant: (variantId) => cartHold.isHeldOut(variantId) }); // leave some variants out
772
+ ```
773
+
774
+ | `sessionKind` | API | The list | One order |
775
+ | --- | --- | --- | --- |
776
+ | `shopify` | Customer Account API | `customer { orders }` (`OrderHistory`) | `order(id:)` (`OrderDetail`) |
777
+ | `password` | Storefront API, with the session's token | `customer(customerAccessToken:) { orders }` (`CustomerOrderHistory`) | Its place in the list (`CustomerOrderIds`, ids only, 250 a page), then that one order (`CustomerOrderDetail`): the Storefront API has no order-by-id query |
778
+
779
+ - **Signed out**, both are empty, with no error, and nothing is sent. While a stored session is read
780
+ on app open, `loading` is true, so a returning shopper doesn't see "sign in" or "no orders" first.
781
+ - **`loading` is true only before the first answer.** A `refresh()` keeps what is on screen, and a
782
+ failed one keeps it and sets `error`; the next read that works clears it.
783
+ - **A refresh joins a read already on its way**, so a screen that refreshes on focus reads once on
784
+ mount, not twice. It re-reads the first page and keeps the pages loaded after it, so a list scrolled
785
+ down stays where it was (those pages aren't re-read; the first page is laid over them).
786
+ - **Paging is by cursor**: `hasMore`, `loadMore()` (it does nothing while another read is on its way),
787
+ `loadingMore`. A page is `pageSize` orders (default `ORDERS_PAGE_SIZE`, 25), newest first by
788
+ `processedAt`, the date the screens show.
789
+ - **Another shopper's orders don't stay**: signing out empties both at once, and so does a session
790
+ of the other kind or a different customer's profile arriving, before reading again. The profile
791
+ arriving after the session began (app open, sign-in) is the same shopper and reads nothing again.
792
+ - `progress`: `cancelled` when the order was cancelled, whatever its fulfilment; else `fulfilled`,
793
+ `partiallyFulfilled`, or `confirmed` for every other fulfilment status.
794
+ - `itemCount` adds up the quantities. A list row counts an order's first 30 lines (enough for a
795
+ list, and it keeps a page of 25 near 850 Customer Account API cost points); `useOrder` reads up to
796
+ 100 lines.
797
+ - Totals: `subtotal` is the lines before discounts and `totalDiscount` the gap between that and
798
+ Shopify's subtotal, so a whole-order code is counted too. `totalDiscount`, `totalTax` and
799
+ `totalRefunded` are null when there is none; `totalShipping` keeps a zero (free shipping).
800
+ `discountCodes` are the codes entered; automatic discounts have none.
801
+ - Tracking, addresses and returns are on Shopify's status page: `statusPageUrl` (the Storefront API's
802
+ `statusUrl`).
803
+
804
+ **Buy again** puts the order's lines back in the cart through the cart's own add (`addLines`: the
805
+ `cartGuard`, the line limit judged on the whole batch, one `cart:add`):
806
+
807
+ - Stock is read fresh first (`variants.byIds`). A line whose variant is gone or not for sale is
808
+ skipped. A quantity over what is left is cut to it, counting what the cart already holds and what
809
+ earlier lines of the order take (`stockCeiling`, the rule `useAddToCart` checks). Untracked stock,
810
+ or overselling allowed, has no ceiling.
811
+ - `skipVariant(variantId)`: a line whose variant it returns true for is skipped before anything is
812
+ added or claimed. Pass Cart Hold's held out, as above, so a size whose every unit was just found
813
+ held in other carts isn't tried again. Wrap it in an arrow: `isHeldOut` is a method of the client.
814
+ - **One summary, no message per line.** The add is quiet (`addLines(inputs, { quiet: true })`, see
815
+ Cart guard): the guard says nothing about a line it refuses (no Cart Hold toast), and a line Shopify
816
+ refuses is dropped and the rest kept without a `cart:outOfStock`. The screen shows one message
817
+ built from `added` and `skipped`. `cart:add` still fires when something lands.
818
+ - It never throws. `added` and `skipped` count order lines. A line cut short still counts as added:
819
+ cut to the stock left, or by the guard (Cart Hold takes only the units still free: 3 ordered and 2
820
+ free adds 2). **A cut isn't reported**: `added` can't tell 2 of 3 from 3 of 3. Offline, every line
821
+ counts as skipped.
822
+ - A variant still sold out and enrolled in pre-order goes back on its selling plan, at the ordered quantity (the Waitlist's pre-order rule); everything else goes back as an ordinary line by the stock rule. Production sent no selling plan.
823
+
824
+ Tested in `test/orders.test.mjs` (44 checks: both sign-ins, paging, the refresh keeping the list, a
825
+ failed refresh, the progress rule, not found and signed out, and Buy again with an unavailable line,
826
+ a cut quantity, everything added, pre-orders, `skipVariant`, and through a guard: told quiet, a line
827
+ it cuts, a line it refuses).
828
+
829
+ **The order just placed (0.10, SDK move 6).** For an Order Confirmed page:
830
+
831
+ ```tsx
832
+ const { order } = useLatestOrderSince(checkoutOpenedAt); // ms, when checkout opened on the phone
833
+ order?.name; // "#1043", or no order yet
834
+ ```
835
+
836
+ - `order` is the shopper's newest order, but only once it was placed at or after `since`, less 2
837
+ minutes (`ORDER_CLOCK_LEEWAY_MS`) for the phone's clock running ahead of Shopify's. Until Shopify
838
+ lists it, the newest is the order before, so `order` stays null: no number rather than a wrong one.
839
+ - While it isn't listed, the newest is read again after 3 and 8 seconds
840
+ (`LATEST_ORDER_READ_AGAIN_SECONDS`; `{ readAgainAfterSeconds }` to change). Then it stops.
841
+ - Signed out, nothing is read and `order` is null (a guest's order isn't known in the app).
842
+ - `since` left out takes the newest order, whatever its date. It reads a page of one order.
843
+ - Tested in `test/latest-order.test.mjs` (10 checks).
844
+
331
845
  ### Checkout
332
846
 
333
- Checkout is Shopify-hosted, so the SDK cannot see the outcome. Report it from the
847
+ **Getting the cart ready (0.10, SDK move 6).** Every way into checkout (the cart, a product page's
848
+ Buy now, a waitlist) runs the same steps first:
849
+
850
+ ```tsx
851
+ const { prepare, preparing } = useCheckout();
852
+ const result = await prepare({ hasLapsedHold }); // Cart Hold's, when the app has reservations
853
+ if (result === 'ready') navigation.navigate('Checkout');
854
+ else if (result === 'lapsedHold') navigation.navigate('Cart', { expired: true });
855
+ else if (result === 'empty') showToast('Your reservation expired and the item was released.');
856
+ else showToast("Couldn't open checkout. Try again."); // 'failed'
857
+ ```
858
+
859
+ 1. **The cart is read again.** No cart, or no lines (a reservation that ran out took them), is
860
+ `'empty'`. A read that fails (offline) carries on with the cart already loaded: Shopify's checkout
861
+ checks it again itself (decided 2026-10-06, Head of Engineering: "Carry on").
862
+ 2. **A reservation that ran out** (`hasLapsedHold(lines)`, asked about the lines just read) is
863
+ `'lapsedHold'`, and nothing else happens: Shopify would empty that line during checkout.
864
+ 3. **Together, neither able to stop checkout:**
865
+ - the signed-in shopper is attached (`setBuyerIdentity`): their token, the profile's email when it
866
+ has loaded, and the cart's country, sent again because the write replaces the whole identity and
867
+ a gift card stays only on a cart with a country. A failure leaves a guest checkout.
868
+ - the cart is labelled: `flushAttribution()`, then `ensureCartAttributes` with the provider's
869
+ `cartAttributes` (no request when they're already there). Each gets 3 seconds
870
+ (`CHECKOUT_LABEL_STEP_TIMEOUT_MS`); one that fails or runs out of time is warned about. (Both
871
+ need 0.9.1's attribution; a build without them skips this.)
872
+ 4. **The start is reported** (`reportCheckoutStarted`), so a cart gone to checkout isn't refilled on
873
+ the next launch.
874
+ 5. `'ready'`. Anything else that throws is `'failed'`. `prepare` never throws.
875
+
876
+ `preparing` is true while this hook's own `prepare` runs, so only the button that started it shows a
877
+ spinner. `prepare` keeps one identity. Tested in `test/checkout-prepare.test.mjs` (28 checks).
878
+
879
+ **The outcome.** Checkout is Shopify-hosted, so the SDK cannot see the outcome. Report it from the
334
880
  webview and the configured copy comes back on the event:
335
881
 
336
882
  ```tsx
@@ -338,8 +884,54 @@ const { reportOrderPlaced, reportPaymentFailed } = useCheckout();
338
884
  // reportOrderPlaced also resets the cart — the old one is spent.
339
885
  ```
340
886
 
341
- > A `checkout.observe(url)` helper that classifies the return URL itself is the
342
- > next piece of work; this is the seam it will emit through.
887
+ **Noticing the order (0.10).** The checkout's address is the only sign a web view gets that the order
888
+ was placed. Two pure helpers from the main entry (no React) read it:
889
+
890
+ - `isOrderPlacedUrl(url: string | null | undefined): boolean` is true for a page Shopify shows once the
891
+ order is placed: `/thank-you` or `/thank_you`, `/confirmation` (where one-page checkout lands) and
892
+ the order status page under `/orders/` (`/<shop id>/orders/<token>`).
893
+ - **Nothing under `/account/` counts.** A signed-in checkout shows an account menu, and an order
894
+ opened from it (`/account/orders/<id>`, or `/<shop id>/account/orders/<id>`) is an old order.
895
+ Counting it would send `purchase` and clear the cart the moment a shopper looked at a past
896
+ order. The order history list (`/account/orders`) doesn't count either.
897
+ - Only the path is read: a query or fragment that names one of these pages (`?return=/orders/1`)
898
+ never counts. Letter case doesn't matter.
899
+ - Each name must be a whole part of the path: `/pages/thank-you-for-subscribing` and
900
+ `/confirmations` don't count.
901
+ - It keeps nothing between calls, so asking twice gives the same answer.
902
+ - `REPORT_ADDRESS_CHANGES_SCRIPT` is the script for the web view's `injectedJavaScript`. It posts
903
+ `{ kind, url }` as JSON each time the page's address changes. `kind` is `load`, `pushState`,
904
+ `replaceState`, `popstate` or `hashchange`.
905
+ - The web view's own navigation event reports whole-page loads only. Shopify can reach the thank-you
906
+ page through `history.replaceState`, which only the script sees.
907
+ - It installs once per page, and does nothing where there's no web view to post to.
908
+
909
+ ```tsx
910
+ import { isOrderPlacedUrl, REPORT_ADDRESS_CHANGES_SCRIPT, useCheckout } from '@tiledev/sdk-shopify';
911
+
912
+ const { reportOrderPlaced } = useCheckout();
913
+ const reported = useRef(false);
914
+ const pageMovedTo = (url: string) => {
915
+ if (reported.current || !isOrderPlacedUrl(url)) return;
916
+ reported.current = true; // the page arrives several times: a replaceState, a pushState, late loads
917
+ void reportOrderPlaced();
918
+ };
919
+
920
+ <WebView
921
+ source={{ uri: cart.checkoutUrl }}
922
+ injectedJavaScript={REPORT_ADDRESS_CHANGES_SCRIPT}
923
+ onNavigationStateChange={(event) => pageMovedTo(event.url)}
924
+ onMessage={(event) => {
925
+ try {
926
+ const message = JSON.parse(event.nativeEvent.data);
927
+ if (typeof message?.url === 'string') pageMovedTo(message.url);
928
+ } catch {}
929
+ }}
930
+ />;
931
+ ```
932
+
933
+ Report the order **once per checkout**: the same page arrives several times. Tested in
934
+ `test/checkout.test.mjs` (20 checks; the script runs in jsdom).
343
935
 
344
936
  ### Where cart units came from (0.9.0)
345
937
 
@@ -357,6 +949,10 @@ updateLine(lineId, 3, undefined, { source: { type: 'replay', showId: streamingId
357
949
 
358
950
  - No `source` means `{ type: 'app' }`. Decreases and removals take units off app first, then
359
951
  replays, then live shows, oldest first.
952
+ - A product page or variant sheet passes it once: `useProductPage(handle, { source })` (or
953
+ `useAddToCart`) gives it to every add from the page, the stepper's + included (0.10, SDK move 6).
954
+ - `useCheckout().prepare()` runs `flushAttribution()` and `ensureCartAttributes(cartAttributes)`
955
+ before checkout (0.10, SDK move 6), so an app needn't.
360
956
  - It is written after the line write lands, one write at a time. A failed write never fails the
361
957
  line write; the next change writes the missed counts too. Every other cart attribute is kept.
362
958
  - Before opening checkout, `await flushAttribution()` (from `useCart()`) so a last failed write is
@@ -367,54 +963,57 @@ updateLine(lineId, 3, undefined, { source: { type: 'replay', showId: streamingId
367
963
  adopted cart's value.
368
964
  - `parseAttribution`, `serializeAttribution`, `recordAdd`, `recordRemove` and `mergeAttribution`
369
965
  are exported for code that rebuilds carts itself (Cart Assist merges two carts' values).
966
+ - **`showsInCart(cart): { live: string[]; replay: string[] }`** (0.10, SDK move 7): the shows a cart
967
+ holds units from, both by the show's streaming id (a replay is counted under the show it records).
968
+ A show whose units all left the cart isn't listed. For the analytics events `streamCheckout` and
969
+ `streamPurchase` (`@tiledev/sdk-analytics-core`'s `streamCheckoutParams`), sent beside
970
+ `initiateCheckout` and `purchase` when either list has a show (Freckled Poppy's `streamAttribution`).
370
971
  - `shopify.cart.updateAttributes` still replaces the whole set: send the cart's current attributes,
371
972
  `_apptile_attribution` included.
372
973
 
373
974
  ## Tile Credit
374
975
 
375
- Customer wallet + gift-card mint + one-shot apply to a Shopify cart.
976
+ Tile Credit is Apptile's store-credit wallet: a service (`https://tile-credit.apptile.io`) that holds each
977
+ customer's balance and turns it into Shopify gift cards. In a React app, use `useStoreCredit`,
978
+ `useStoreCreditHistory` and `useCartStoreCredit` (above) with `storeCredit={{ source: 'tile' }}` on the
979
+ provider: they build their client from the provider's session. Without React:
376
980
 
377
981
  ```ts
378
982
  import {shopify, centsToMoney} from '@tiledev/sdk-shopify';
379
983
 
380
- // Configure once per signed-in customer (rebuild on logout / new customer).
381
984
  shopify.tileCredit.configure({
382
- baseUrl: 'https://tile-credit-1097788179850.us-central1.run.app',
383
- customerAccessToken, // shcat_… or classic Storefront customer token
985
+ // baseUrl is optional: https://tile-credit.apptile.io by default.
384
986
  shopDomain: 'yourshop.myshopify.com',
987
+ getAccessToken: () => session.getAccessToken(), // read before every request
988
+ renewAccessToken: () => session.renewAccessToken(), // called once on a 401, then the request is sent again
385
989
  });
386
990
 
387
991
  const client = shopify.tileCredit.client()!;
388
992
  const wallet = await client.getWallet();
389
993
  console.log('balance:', centsToMoney(wallet.balanceCents));
390
-
391
- // Redeem 15.00 AND apply the minted gift card to the current cart in one call.
392
- const {redeemed, cart} = await shopify.tileCredit.redeemAndApplyToCart({
393
- cartId,
394
- amountCents: 1500,
395
- });
396
994
  ```
397
995
 
398
- React hook:
996
+ **How the money moves.** Redeeming reserves, it doesn't charge: `redeem` mints a Shopify gift card and
997
+ reserves its amount, and the wallet is charged only for what an order uses, so the balance doesn't change
998
+ on a redeem. One card is active per customer, and a new redeem disables the previous one. The same
999
+ `idempotencyKey` returns the same card (`duplicate: true`), but two redeems at once with different keys
1000
+ mint two cards: allow one at a time.
399
1001
 
400
- ```tsx
401
- import {useTileCredit} from '@tiledev/sdk-shopify/react';
1002
+ **Auth.** `Authorization: Customer <token>` (a `shcat_…` Customer Account API token or a classic
1003
+ Storefront one) and `x-shopify-shop-domain`. A 401 means the token was refused, or the service doesn't
1004
+ know the shop or has no credentials for it; the client renews the token once and never signs anyone out.
402
1005
 
403
- function WalletScreen() {
404
- const {wallet, config, redeemAndApply, refresh, loading, error} = useTileCredit({
405
- baseUrl: TILE_CREDIT_URL,
406
- customerAccessToken: session.token,
407
- shopDomain: SHOP_DOMAIN,
408
- });
409
- // …
410
- }
411
- ```
1006
+ **Errors.** Every method rejects with a `TileCreditError`; branch on `.code`, not `.message`:
1007
+ `unauthorized` (401), `forbidden` (403), `not_found` (404), `validation` (400: under the store's minimum
1008
+ or over its maximum, `details.min`/`max`), `insufficient_balance` (402), `conflict` (409),
1009
+ `rate_limited` (429), `shopify_upstream` (502), `internal` (other 5xx), `network` (unreachable or past
1010
+ `timeoutMs`), and `cart_refused` (the card was made but didn't go on the cart).
412
1011
 
413
- Full integration guide (idempotency, error taxonomy, recovery playbook)
414
- lives in the mobile app repo alongside the wallet screen — this SDK ships
415
- the client + types only. Every method rejects with a `TileCreditError`
416
- (`.code`: `'unauthorized' | 'insufficient_balance' | 'shopify_upstream'
417
- | 'network' | …`). Branch on `.code`, not `.message`.
1012
+ `shopify.tileCredit.redeemAndApplyToCart` and `useTileCredit` are **deprecated** in favour of
1013
+ `useCartStoreCredit`. Both were fixed on 2026-10-05: the card is added beside the cart's other gift cards
1014
+ (they used to replace them), the cart's country is set only when it has none, keeping its email and the
1015
+ shopper's link (they used to replace the identity with the country alone), and `useTileCredit` no longer
1016
+ shows one shopper's wallet to the next.
418
1017
 
419
1018
  ## Types
420
1019
 
@@ -423,6 +1022,7 @@ import type {
423
1022
  Product, ProductVariant, ProductOption,
424
1023
  Cart, CartLine, CartLineInput, CartLineUpdateInput,
425
1024
  Collection, Customer, Order, Blog, Article,
1025
+ OrderSummary, OrderDetails, OrderLine, OrderProgress,
426
1026
  WishlistItem, WishlistStorageAdapter,
427
1027
  ShopifyConfig, ShopifyIntegration,
428
1028
  ShopifyError,