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