@sixtyninecommerce/client 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +574 -14
- package/dist/admin/http.d.ts.map +1 -1
- package/dist/admin/http.js.map +1 -1
- package/dist/admin/types.d.ts.map +1 -1
- package/dist/build-client.d.ts +9 -0
- package/dist/build-client.d.ts.map +1 -0
- package/dist/build-client.js +58 -0
- package/dist/build-client.js.map +1 -0
- package/dist/cache-tags.d.ts +35 -1
- package/dist/cache-tags.d.ts.map +1 -1
- package/dist/cache-tags.js +39 -1
- package/dist/cache-tags.js.map +1 -1
- package/dist/client.d.ts +25 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -62
- package/dist/client.js.map +1 -1
- package/dist/commerce/age-verification.d.ts +7 -4
- package/dist/commerce/age-verification.d.ts.map +1 -1
- package/dist/commerce/age-verification.js +6 -4
- package/dist/commerce/age-verification.js.map +1 -1
- package/dist/commerce/arguments.d.ts +13 -0
- package/dist/commerce/arguments.d.ts.map +1 -0
- package/dist/commerce/arguments.js +19 -0
- package/dist/commerce/arguments.js.map +1 -0
- package/dist/commerce/branding.d.ts.map +1 -1
- package/dist/commerce/branding.js.map +1 -1
- package/dist/commerce/capabilities.d.ts +40 -0
- package/dist/commerce/capabilities.d.ts.map +1 -0
- package/dist/commerce/capabilities.js +41 -0
- package/dist/commerce/capabilities.js.map +1 -0
- package/dist/commerce/cart.d.ts +9 -12
- package/dist/commerce/cart.d.ts.map +1 -1
- package/dist/commerce/cart.js +6 -1
- package/dist/commerce/cart.js.map +1 -1
- package/dist/commerce/checkout.d.ts +95 -15
- package/dist/commerce/checkout.d.ts.map +1 -1
- package/dist/commerce/checkout.js +230 -10
- package/dist/commerce/checkout.js.map +1 -1
- package/dist/commerce/customer.d.ts +83 -38
- package/dist/commerce/customer.d.ts.map +1 -1
- package/dist/commerce/customer.js +149 -10
- package/dist/commerce/customer.js.map +1 -1
- package/dist/commerce/favorites.d.ts +2 -5
- package/dist/commerce/favorites.d.ts.map +1 -1
- package/dist/commerce/favorites.js.map +1 -1
- package/dist/commerce/index.d.ts +194 -133
- package/dist/commerce/index.d.ts.map +1 -1
- package/dist/commerce/index.js +15 -0
- package/dist/commerce/index.js.map +1 -1
- package/dist/commerce/order-states.d.ts +18 -0
- package/dist/commerce/order-states.d.ts.map +1 -0
- package/dist/commerce/order-states.js +30 -0
- package/dist/commerce/order-states.js.map +1 -0
- package/dist/commerce/parcel-pricing.d.ts +48 -0
- package/dist/commerce/parcel-pricing.d.ts.map +1 -0
- package/dist/commerce/parcel-pricing.js +39 -0
- package/dist/commerce/parcel-pricing.js.map +1 -0
- package/dist/commerce/parked-orders.d.ts +22 -0
- package/dist/commerce/parked-orders.d.ts.map +1 -0
- package/dist/commerce/parked-orders.js +47 -0
- package/dist/commerce/parked-orders.js.map +1 -0
- package/dist/commerce/payments.d.ts +12 -5
- package/dist/commerce/payments.d.ts.map +1 -1
- package/dist/commerce/payments.js +10 -1
- package/dist/commerce/payments.js.map +1 -1
- package/dist/commerce/placement-error.d.ts +17 -0
- package/dist/commerce/placement-error.d.ts.map +1 -1
- package/dist/commerce/placement-error.js.map +1 -1
- package/dist/commerce/products.d.ts +92 -38
- package/dist/commerce/products.d.ts.map +1 -1
- package/dist/commerce/products.js +157 -123
- package/dist/commerce/products.js.map +1 -1
- package/dist/commerce/queries.d.ts +216 -49
- package/dist/commerce/queries.d.ts.map +1 -1
- package/dist/commerce/queries.js +1347 -743
- package/dist/commerce/queries.js.map +1 -1
- package/dist/commerce/quotes.d.ts +5 -4
- package/dist/commerce/quotes.d.ts.map +1 -1
- package/dist/commerce/quotes.js +1 -36
- package/dist/commerce/quotes.js.map +1 -1
- package/dist/commerce/receipt-claim.d.ts +35 -0
- package/dist/commerce/receipt-claim.d.ts.map +1 -0
- package/dist/commerce/receipt-claim.js +66 -0
- package/dist/commerce/receipt-claim.js.map +1 -0
- package/dist/commerce/recommendations.d.ts +3 -3
- package/dist/commerce/recommendations.d.ts.map +1 -1
- package/dist/commerce/recommendations.js +3 -1
- package/dist/commerce/recommendations.js.map +1 -1
- package/dist/commerce/returns.d.ts +7 -6
- package/dist/commerce/returns.d.ts.map +1 -1
- package/dist/commerce/returns.js +24 -3
- package/dist/commerce/returns.js.map +1 -1
- package/dist/commerce/reviews.d.ts +24 -15
- package/dist/commerce/reviews.d.ts.map +1 -1
- package/dist/commerce/reviews.js +67 -50
- package/dist/commerce/reviews.js.map +1 -1
- package/dist/commerce/saved-lists.d.ts +15 -15
- package/dist/commerce/saved-lists.d.ts.map +1 -1
- package/dist/commerce/saved-lists.js +67 -56
- package/dist/commerce/saved-lists.js.map +1 -1
- package/dist/commerce/search.d.ts +3 -2
- package/dist/commerce/search.d.ts.map +1 -1
- package/dist/commerce/search.js +2 -2
- package/dist/commerce/search.js.map +1 -1
- package/dist/commerce/shipping.d.ts +11 -4
- package/dist/commerce/shipping.d.ts.map +1 -1
- package/dist/commerce/shipping.js +17 -2
- package/dist/commerce/shipping.js.map +1 -1
- package/dist/commerce/stock-notifications.d.ts +2 -0
- package/dist/commerce/stock-notifications.d.ts.map +1 -1
- package/dist/commerce/stock-notifications.js +2 -2
- package/dist/commerce/stock-notifications.js.map +1 -1
- package/dist/commerce/store-pickup.d.ts +37 -0
- package/dist/commerce/store-pickup.d.ts.map +1 -0
- package/dist/commerce/store-pickup.js +66 -0
- package/dist/commerce/store-pickup.js.map +1 -0
- package/dist/commerce/subscriptions.d.ts +35 -37
- package/dist/commerce/subscriptions.d.ts.map +1 -1
- package/dist/commerce/subscriptions.js +159 -120
- package/dist/commerce/subscriptions.js.map +1 -1
- package/dist/commerce/vat-validation.d.ts +6 -3
- package/dist/commerce/vat-validation.d.ts.map +1 -1
- package/dist/commerce/vat-validation.js +24 -19
- package/dist/commerce/vat-validation.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js.map +1 -1
- package/dist/content/assets.d.ts +5 -4
- package/dist/content/assets.d.ts.map +1 -1
- package/dist/content/assets.js.map +1 -1
- package/dist/content/collection-pages.d.ts +3 -2
- package/dist/content/collection-pages.d.ts.map +1 -1
- package/dist/content/collection-pages.js.map +1 -1
- package/dist/content/collections.d.ts +5 -4
- package/dist/content/collections.d.ts.map +1 -1
- package/dist/content/collections.js.map +1 -1
- package/dist/content/filtered-products.d.ts.map +1 -1
- package/dist/content/filtered-products.js.map +1 -1
- package/dist/content/form-field-types.d.ts +3 -3
- package/dist/content/form-field-types.d.ts.map +1 -1
- package/dist/content/form-field-types.js.map +1 -1
- package/dist/content/forms.d.ts +4 -3
- package/dist/content/forms.d.ts.map +1 -1
- package/dist/content/forms.js +3 -0
- package/dist/content/forms.js.map +1 -1
- package/dist/content/index.d.ts +18 -17
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/items.d.ts +5 -4
- package/dist/content/items.d.ts.map +1 -1
- package/dist/content/items.js +2 -0
- package/dist/content/items.js.map +1 -1
- package/dist/content/menus.d.ts +3 -2
- package/dist/content/menus.d.ts.map +1 -1
- package/dist/content/menus.js +7 -1
- package/dist/content/menus.js.map +1 -1
- package/dist/content/resolve.d.ts +3 -2
- package/dist/content/resolve.d.ts.map +1 -1
- package/dist/content/resolve.js +1 -1
- package/dist/content/resolve.js.map +1 -1
- package/dist/content/sitemap.d.ts +3 -2
- package/dist/content/sitemap.d.ts.map +1 -1
- package/dist/content/sitemap.js.map +1 -1
- package/dist/content/strings.d.ts +16 -2
- package/dist/content/strings.d.ts.map +1 -1
- package/dist/content/strings.js +32 -4
- package/dist/content/strings.js.map +1 -1
- package/dist/core/context.d.ts +127 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +436 -45
- package/dist/core/context.js.map +1 -1
- package/dist/core/currency.d.ts +14 -0
- package/dist/core/currency.d.ts.map +1 -0
- package/dist/core/currency.js +52 -0
- package/dist/core/currency.js.map +1 -0
- package/dist/core/deprecation.d.ts +23 -0
- package/dist/core/deprecation.d.ts.map +1 -0
- package/dist/core/deprecation.js +81 -0
- package/dist/core/deprecation.js.map +1 -0
- package/dist/core/http.d.ts +15 -1
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +55 -3
- package/dist/core/http.js.map +1 -1
- package/dist/core/public-artifact.d.ts +72 -0
- package/dist/core/public-artifact.d.ts.map +1 -0
- package/dist/core/public-artifact.js +122 -0
- package/dist/core/public-artifact.js.map +1 -0
- package/dist/core/unknown-field-rejection.d.ts +18 -0
- package/dist/core/unknown-field-rejection.d.ts.map +1 -0
- package/dist/core/unknown-field-rejection.js +37 -0
- package/dist/core/unknown-field-rejection.js.map +1 -0
- package/dist/credit/credit-status.d.ts +2 -0
- package/dist/credit/credit-status.d.ts.map +1 -1
- package/dist/credit/credit-status.js +71 -19
- package/dist/credit/credit-status.js.map +1 -1
- package/dist/credit/index.d.ts +1 -1
- package/dist/credit/payment-terms.d.ts +3 -3
- package/dist/credit/payment-terms.d.ts.map +1 -1
- package/dist/credit/payment-terms.js +28 -24
- package/dist/credit/payment-terms.js.map +1 -1
- package/dist/currency/exchange.d.ts +7 -7
- package/dist/currency/exchange.d.ts.map +1 -1
- package/dist/currency/exchange.js +5 -1
- package/dist/currency/exchange.js.map +1 -1
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/events/index.d.ts +20 -7
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +17 -5
- package/dist/events/index.js.map +1 -1
- package/dist/events/payloads.d.ts +7 -4
- package/dist/events/payloads.d.ts.map +1 -1
- package/dist/events/payloads.js +6 -3
- package/dist/events/payloads.js.map +1 -1
- package/dist/feeds/index.d.ts +5 -0
- package/dist/feeds/index.d.ts.map +1 -0
- package/dist/feeds/index.js +12 -0
- package/dist/feeds/index.js.map +1 -0
- package/dist/feeds/types.d.ts +18 -0
- package/dist/feeds/types.d.ts.map +1 -0
- package/dist/feeds/types.js +8 -0
- package/dist/feeds/types.js.map +1 -0
- package/dist/generated/cms.d.ts +2136 -0
- package/dist/generated/cms.d.ts.map +1 -0
- package/dist/generated/cms.js +4 -0
- package/dist/generated/cms.js.map +1 -0
- package/dist/generated/plugins.d.ts +550 -0
- package/dist/generated/plugins.d.ts.map +1 -0
- package/dist/generated/plugins.js +4 -0
- package/dist/generated/plugins.js.map +1 -0
- package/dist/generated/shop.d.ts +5663 -0
- package/dist/generated/shop.d.ts.map +1 -0
- package/dist/generated/shop.js +5 -0
- package/dist/generated/shop.js.map +1 -0
- package/dist/graphql-document.d.ts +29 -0
- package/dist/graphql-document.d.ts.map +1 -0
- package/dist/graphql-document.js +21 -0
- package/dist/graphql-document.js.map +1 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/media/urls.d.ts.map +1 -1
- package/dist/media/urls.js +12 -1
- package/dist/media/urls.js.map +1 -1
- package/dist/media/video.d.ts.map +1 -1
- package/dist/media/video.js.map +1 -1
- package/dist/multi-store.d.ts +200 -0
- package/dist/multi-store.d.ts.map +1 -0
- package/dist/multi-store.js +558 -0
- package/dist/multi-store.js.map +1 -0
- package/dist/navigation/routes.d.ts +4 -3
- package/dist/navigation/routes.d.ts.map +1 -1
- package/dist/navigation/routes.js.map +1 -1
- package/dist/next/artifact-handler.d.ts +21 -13
- package/dist/next/artifact-handler.d.ts.map +1 -1
- package/dist/next/artifact-handler.js +28 -66
- package/dist/next/artifact-handler.js.map +1 -1
- package/dist/next/event-revalidate-handler.d.ts +26 -6
- package/dist/next/event-revalidate-handler.d.ts.map +1 -1
- package/dist/next/event-revalidate-handler.js +126 -19
- package/dist/next/event-revalidate-handler.js.map +1 -1
- package/dist/next/feed-handler.d.ts +50 -0
- package/dist/next/feed-handler.d.ts.map +1 -0
- package/dist/next/feed-handler.js +61 -0
- package/dist/next/feed-handler.js.map +1 -0
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/metadata.d.ts.map +1 -1
- package/dist/next/metadata.js.map +1 -1
- package/dist/next/redirect-middleware.d.ts +5 -31
- package/dist/next/redirect-middleware.d.ts.map +1 -1
- package/dist/next/redirect-middleware.js +4 -46
- package/dist/next/redirect-middleware.js.map +1 -1
- package/dist/next/revalidate-handler.d.ts.map +1 -1
- package/dist/next/revalidate-handler.js.map +1 -1
- package/dist/pricing/index.d.ts.map +1 -1
- package/dist/pricing/index.js.map +1 -1
- package/dist/pricing/tax-display.js +1 -1
- package/dist/pricing/tax-display.js.map +1 -1
- package/dist/pricing/tiers.d.ts +3 -2
- package/dist/pricing/tiers.d.ts.map +1 -1
- package/dist/pricing/tiers.js.map +1 -1
- package/dist/pricing/validation.d.ts +88 -12
- package/dist/pricing/validation.d.ts.map +1 -1
- package/dist/pricing/validation.js +93 -12
- package/dist/pricing/validation.js.map +1 -1
- package/dist/redirects/index.d.ts.map +1 -1
- package/dist/redirects/index.js +2 -1
- package/dist/redirects/index.js.map +1 -1
- package/dist/redirects/store.d.ts.map +1 -1
- package/dist/redirects/store.js +6 -2
- package/dist/redirects/store.js.map +1 -1
- package/dist/redirects/types.d.ts +5 -2
- package/dist/redirects/types.d.ts.map +1 -1
- package/dist/revalidation/event-plan.d.ts +55 -4
- package/dist/revalidation/event-plan.d.ts.map +1 -1
- package/dist/revalidation/event-plan.js +115 -8
- package/dist/revalidation/event-plan.js.map +1 -1
- package/dist/revalidation/event-verify.d.ts +8 -37
- package/dist/revalidation/event-verify.d.ts.map +1 -1
- package/dist/revalidation/event-verify.js +7 -97
- package/dist/revalidation/event-verify.js.map +1 -1
- package/dist/revalidation/types.d.ts.map +1 -1
- package/dist/revalidation/url-context.d.ts +17 -0
- package/dist/revalidation/url-context.d.ts.map +1 -1
- package/dist/revalidation/url-context.js +35 -2
- package/dist/revalidation/url-context.js.map +1 -1
- package/dist/seo/ai.d.ts.map +1 -1
- package/dist/seo/ai.js.map +1 -1
- package/dist/seo/artifacts.d.ts +86 -4
- package/dist/seo/artifacts.d.ts.map +1 -1
- package/dist/seo/artifacts.js +115 -16
- package/dist/seo/artifacts.js.map +1 -1
- package/dist/seo/availability.d.ts +2 -2
- package/dist/seo/availability.d.ts.map +1 -1
- package/dist/seo/availability.js.map +1 -1
- package/dist/seo/index.d.ts +2 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +3 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/json-ld.d.ts.map +1 -1
- package/dist/seo/json-ld.js.map +1 -1
- package/dist/seo/meta-tags.d.ts.map +1 -1
- package/dist/seo/meta-tags.js.map +1 -1
- package/dist/seo/metadata-parser.d.ts.map +1 -1
- package/dist/seo/metadata-parser.js.map +1 -1
- package/dist/seo/metadata-types.d.ts.map +1 -1
- package/dist/seo/robots.d.ts.map +1 -1
- package/dist/seo/robots.js.map +1 -1
- package/dist/seo/types.d.ts +9 -2
- package/dist/seo/types.d.ts.map +1 -1
- package/dist/seo/url-builder.d.ts +5 -3
- package/dist/seo/url-builder.d.ts.map +1 -1
- package/dist/seo/url-builder.js +3 -1
- package/dist/seo/url-builder.js.map +1 -1
- package/dist/types.d.ts +1381 -2537
- package/dist/types.d.ts.map +1 -1
- package/dist/web/artifact-responder.d.ts +69 -0
- package/dist/web/artifact-responder.d.ts.map +1 -0
- package/dist/web/artifact-responder.js +90 -0
- package/dist/web/artifact-responder.js.map +1 -0
- package/dist/web/feed-responder.d.ts +72 -0
- package/dist/web/feed-responder.d.ts.map +1 -0
- package/dist/web/feed-responder.js +81 -0
- package/dist/web/feed-responder.js.map +1 -0
- package/dist/web/index.d.ts +23 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +19 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/redirect-middleware.d.ts +76 -0
- package/dist/web/redirect-middleware.d.ts.map +1 -0
- package/dist/web/redirect-middleware.js +94 -0
- package/dist/web/redirect-middleware.js.map +1 -0
- package/dist/web/respond.d.ts +20 -0
- package/dist/web/respond.d.ts.map +1 -0
- package/dist/web/respond.js +52 -0
- package/dist/web/respond.js.map +1 -0
- package/package.json +18 -2
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ const results = await client.commerce.searchProducts(
|
|
|
125
125
|
// Pricing helpers
|
|
126
126
|
client.pricing.formatPrice(2999, 'EUR'); // "€29.99"
|
|
127
127
|
client.pricing.getTierForQuantity(variant.priceTiers, 10);
|
|
128
|
-
client.pricing.validateOrderMinimums(order,
|
|
128
|
+
client.pricing.validateOrderMinimums(order, await client.commerce.getOrderConstraints());
|
|
129
129
|
|
|
130
130
|
// Media helpers
|
|
131
131
|
client.media.assetUrl('shop/source/photo.jpg');
|
|
@@ -135,11 +135,91 @@ client.media.getResponsiveImageData(asset); // { src, srcSet, sizes, alt }
|
|
|
135
135
|
// Language & auth
|
|
136
136
|
client.setLanguage('nl');
|
|
137
137
|
client.setAuthToken(token);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Several stores in one process
|
|
141
|
+
|
|
142
|
+
One storefront app can serve several stores. Pass `resolveStore` instead of `storeCode`, and you
|
|
143
|
+
get a **store router** back (not a promise). Each request asks it for a client of its own:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { createStorefrontClient, storeFromHostMap } from '@sixtyninecommerce/client';
|
|
147
|
+
|
|
148
|
+
export const stores = createStorefrontClient({
|
|
149
|
+
cdnUrl: 'https://cdn.example.com',
|
|
150
|
+
// Your rule for which store a request belongs to. Return null for "none".
|
|
151
|
+
resolveStore: storeFromHostMap({
|
|
152
|
+
'fashion.example.com': 'fashion-store',
|
|
153
|
+
'trade.fashion.example.com': { storeCode: 'fashion-store', head: 'trade' },
|
|
154
|
+
'gear.example.com': { storeCode: 'gear-store', language: 'nl' },
|
|
155
|
+
}),
|
|
156
|
+
stores: ['fashion-store', 'gear-store'], // optional allow-list
|
|
157
|
+
privateToken: { 'fashion-store': process.env.FASHION_TOKEN! }, // per store, server only
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Per request (a route handler, a server component, middleware):
|
|
161
|
+
const client = await stores.forRequest(request, {
|
|
162
|
+
customerToken: cookies.get('token'), // this shopper's session
|
|
163
|
+
currencyCode: cookies.get('currency'), // this shopper's currency
|
|
164
|
+
buyerIp: clientAddress, // sent only to a store with a private token
|
|
165
|
+
onAuthToken: (token) => cookies.set('token', token),
|
|
166
|
+
});
|
|
167
|
+
await client.commerce.getProduct('blue-shirt');
|
|
168
|
+
|
|
169
|
+
// Or name the store yourself:
|
|
170
|
+
const gear = await stores.forStore('gear-store');
|
|
171
|
+
```
|
|
138
172
|
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
- **Nothing a shopper does crosses requests.** The sign-in, language, currency, order scope, head
|
|
174
|
+
and buyer address belong to the client `forRequest`/`forStore` returned and to nothing else —
|
|
175
|
+
two concurrent requests, for one store or two, never send each other's. That is also why the
|
|
176
|
+
router's config refuses `customerToken`, `currencyCode` and `buyerIp`: pass them per request.
|
|
177
|
+
- **What a store shares** between its requests: the channel (read once, refreshed when the
|
|
178
|
+
platform's answer changes), its CDN, its redirect map and its private token. One channel lookup
|
|
179
|
+
serves every store.
|
|
180
|
+
- **Private tokens are per store.** A token is bound to one channel, and the platform ignores it on
|
|
181
|
+
any other store's requests, so `privateToken` is `{ [storeCode]: token }` or a function.
|
|
182
|
+
- **Which store** is your rule (`resolveStore(request) → code | { storeCode, head?, language? } |
|
|
183
|
+
null`). Two ready-made rules for a fixed list: `storeFromHostMap` (the `Host` header the server
|
|
184
|
+
received — exact, then on any port — and the URL only when there is none, since self-hosted
|
|
185
|
+
Next builds `request.url` on its own host; never `X-Forwarded-Host`, which a visitor can send)
|
|
186
|
+
and `storeFromPathPrefix`
|
|
187
|
+
(`{ '/nl': 'store-nl', '/be': 'store-be' }`, whole segments, longest prefix first). Host keys
|
|
188
|
+
may be written in Unicode or punycode (`bücher.example` = `xn--bcher-kva.example`). The URL you
|
|
189
|
+
pass must be absolute — on a plain `node:http` server, build it from the `Host` you trust.
|
|
190
|
+
- **Refusals** are `StorefrontError`s with status 404, so a page can render its not-found view from
|
|
191
|
+
`error.status`: `STORE_NOT_RESOLVED` (your rule returned null), `STORE_NOT_ALLOWED` (outside
|
|
192
|
+
`stores`), `CHANNEL_NOT_FOUND` (an unknown code, the bootstrap root, or an organization's
|
|
193
|
+
workspace — a hub is never a store). Set `stores` whenever your rule passes on anything taken
|
|
194
|
+
from the request, so a visitor cannot make the process look up codes it does not serve.
|
|
195
|
+
- **Canonical links** are always built on each store's main shop address (`storefrontUrl`),
|
|
196
|
+
whichever host or head the request came in on.
|
|
197
|
+
- **Cache tags** carry the store: every tag a read is sent with also goes out as
|
|
198
|
+
`store:<code>:<tag>` (`storeCacheTag`), so one store's events purge that store's pages only (see
|
|
199
|
+
[Next.js revalidation](#nextjs-revalidation-platform-events)). The `/next` and `/web` adapters
|
|
200
|
+
accept the router too: `createSeoArtifactHandler(stores, 'sitemap')` /
|
|
201
|
+
`createSeoArtifactResponder(stores, 'sitemap')`, `createProductFeedHandler(stores)` /
|
|
202
|
+
`createProductFeedResponder(stores)` and `createRedirectMiddleware({ client: stores, onError })`
|
|
203
|
+
serve each request from its own store, and answer a request no store serves with 404 (the
|
|
204
|
+
middleware falls through; its `onError` also hears when a store's redirect map fails to load).
|
|
205
|
+
The `/next` handlers tag their upstream fetch store-scoped too; `/web` sets no cache tags.
|
|
206
|
+
- **Per router, not per request:** a `conditionalReads: { maxEntries, maxBytes }` cache, and the
|
|
207
|
+
once-per-route deprecation warning, are kept for the router's whole life.
|
|
208
|
+
|
|
209
|
+
### Heads (`head`)
|
|
210
|
+
|
|
211
|
+
A store can have several sites — a consumer shop and a trade portal, say — registered as heads.
|
|
212
|
+
`head` names the one a client speaks for, sent as `x-head`, and the platform composes every link it
|
|
213
|
+
emails for that request (sign-in, magic link, invite, password reset) on that head's address:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
const client = await createStorefrontClient({ storeCode: 'fashion-store', cdnUrl, head: 'trade' });
|
|
141
217
|
```
|
|
142
218
|
|
|
219
|
+
Any key the store registered (lowercase letters, digits, single hyphens); the platform honours a
|
|
220
|
+
`web`, `trade` or `native` head and ignores any other, falling back to the store's default head.
|
|
221
|
+
Unset, nothing is sent — exactly as before. Canonical links do not follow it.
|
|
222
|
+
|
|
143
223
|
## Deprecated: the `/admin` surface
|
|
144
224
|
|
|
145
225
|
`@sixtyninecommerce/client/admin` (`createCommerceAdminClient`) is **deprecated**. Use
|
|
@@ -161,16 +241,77 @@ Five things have no successor yet, and are the reason to stay on this surface:
|
|
|
161
241
|
This surface still works and is unchanged; it will be removed in a future major, not before those
|
|
162
242
|
land.
|
|
163
243
|
|
|
244
|
+
## Deprecated: `client.graphql()`
|
|
245
|
+
|
|
246
|
+
`client.graphql(query, variables)` — a raw Shop API document — is **deprecated**. It keeps
|
|
247
|
+
working until 1.0, and the first call in a process prints one line saying so. Every common use has
|
|
248
|
+
a typed method now:
|
|
249
|
+
|
|
250
|
+
| Instead of a raw query for | Call |
|
|
251
|
+
|---|---|
|
|
252
|
+
| a collection or product by id | `commerce.getCollectionById(id)`, `commerce.getProductById(id)` |
|
|
253
|
+
| storefront metafields | `commerce.getProduct(slug, { metafields: true })` (also `getProductById`, `getCollection`, `getCollectionById`) |
|
|
254
|
+
| the session currency | `createStorefrontClient({ currencyCode })`, `commerce.setOrderCurrency(code)` |
|
|
255
|
+
| what the store is ready to serve | `commerce.getCapabilities()` |
|
|
256
|
+
| one content string | `content.getContentString(id)` |
|
|
257
|
+
| logo, cover, favicon / a CMS asset | `commerce.getBranding()` / `content.getAsset(id)` |
|
|
258
|
+
| a named sign-in strategy, order custom fields, unsetting addresses, order states | `commerce.authenticate`, `setOrderCustomFields`, `unsetShippingAddress` / `unsetBillingAddress`, `getNextOrderStates` / `transitionOrder` |
|
|
259
|
+
|
|
260
|
+
The Shop API fields no method sends yet are listed in the MCP capability catalog
|
|
261
|
+
(`storefront://capability-catalog`). Route the notice to your logger, or silence it, with
|
|
262
|
+
`onDeprecation`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
const client = await createStorefrontClient({
|
|
266
|
+
storeCode: 'fashion-store',
|
|
267
|
+
cdnUrl: 'https://cdn.fashion-store.com',
|
|
268
|
+
// kind 'method' (client.graphql) or 'endpoint' (a platform route sent a Deprecation header)
|
|
269
|
+
onDeprecation: (notice) => logger.warn({ deprecation: notice }),
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Unset, a deprecated method is printed once per process, a deprecated platform route once per
|
|
274
|
+
client. Set, the hook is called once per client for each (once per router with `resolveStore`).
|
|
275
|
+
|
|
164
276
|
## Modules
|
|
165
277
|
|
|
166
278
|
| Module | Description |
|
|
167
279
|
|--------|-------------|
|
|
168
|
-
| `commerce` | Products, collections, property trees, search, cart, checkout, customer auth |
|
|
169
|
-
| `content` | CMS resolve, content items, assets, forms, content strings, menus |
|
|
280
|
+
| `commerce` | Products, collections, property trees, search, cart, checkout, customer auth, the store's capability manifest (`getCapabilities`), store pickup (`getStorePickupLocations`, `chooseStorePickup`, `clearStorePickup`, `getActiveOrderStorePickup`; see `docs/storefront/store-pickup.md`), the receipt account claim (`claimReceipt`; see below) |
|
|
281
|
+
| `content` | CMS resolve, content items, assets, forms, content strings (all, or one by id), menus |
|
|
170
282
|
| `navigation` | Unified route manifest, route lookup |
|
|
171
283
|
| `pricing` | Price formatting, tier lookup, order validation (pure), resolved tax display mode |
|
|
172
284
|
| `media` | CDN URLs, preset URLs, responsive image data (pure functions) |
|
|
173
285
|
| `currency` | Exchange rates, currency conversion |
|
|
286
|
+
| `feeds` | Product-feed URLs (Google Shopping, Meta, Pinterest, TikTok) |
|
|
287
|
+
|
|
288
|
+
## Registration
|
|
289
|
+
|
|
290
|
+
`register` creates the account and **signs nobody in**. The store emails the address a
|
|
291
|
+
"finish creating your account" link; the account works once the shopper opens it and chooses a
|
|
292
|
+
password there, which proves the address is theirs. Opening it signs them in. So after `register`
|
|
293
|
+
show "check your email", never a signed-in page, and do not call `login`: until the link is used,
|
|
294
|
+
a login answers `PASSWORD_RESET_REQUIRED` and emails the link again.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
await client.commerce.register({ emailAddress, firstName, lastName });
|
|
298
|
+
// → { success: true } whether or not the address already has an account (no enumeration).
|
|
299
|
+
showMessage('Check your email to finish creating your account.');
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- **The link lands on the reset route you already serve**: `/reset-password?token=…` (a trade
|
|
303
|
+
account: `/b2b/login/reset?token=…`), finished with `resetPassword(token, password)`, which sets
|
|
304
|
+
the password, verifies the account and starts the session. A magic-link sign-in on the address
|
|
305
|
+
finishes it too. Nothing new to build.
|
|
306
|
+
- **A password sent to `register` is checked, then ignored.** It still answers
|
|
307
|
+
`PASSWORD_VALIDATION_ERROR` for one that breaks the length rule, so a form that keeps the field
|
|
308
|
+
behaves as before, but nothing stores it: drop the field and ask for the password on the reset
|
|
309
|
+
page.
|
|
310
|
+
- **A lost email recovers on its own**: registering again, "forgot password" and a sign-in attempt
|
|
311
|
+
each send the link again (rate-limited like a reset).
|
|
312
|
+
- **`NATIVE_AUTH_STRATEGY_ERROR`** from `register` means the store has no storefront address to
|
|
313
|
+
link the email to; its `message` says so. Nothing was created.
|
|
314
|
+
- The store sends its welcome email once the account is finished, not at registration.
|
|
174
315
|
|
|
175
316
|
## Passwordless (Magic Link) Login
|
|
176
317
|
|
|
@@ -180,9 +321,14 @@ with password login (`login`) — both are available. For unknown emails the ser
|
|
|
180
321
|
creates a verified account on first sign-in (when the channel enables signup).
|
|
181
322
|
|
|
182
323
|
```typescript
|
|
183
|
-
// 1. Request —
|
|
324
|
+
// 1. Request — resolves whether or not the account exists (anti-enumeration).
|
|
325
|
+
// Show "check your inbox" regardless. Throws only on a rate limit or a real error.
|
|
184
326
|
await client.commerce.requestMagicLink(email);
|
|
185
327
|
|
|
328
|
+
// A B2B portal says where the shopper started, so the link opens the B2B page:
|
|
329
|
+
await client.commerce.requestMagicLink(email, { portal: 'B2B' }); // → /b2b/login/magic
|
|
330
|
+
// 'CONSUMER' → /sign-in/magic. Omitted: derived from the account (a B2B customer gets the B2B path).
|
|
331
|
+
|
|
186
332
|
// 2a. Consume the LINK (token from the emailed URL's ?token= param)
|
|
187
333
|
const user = await client.commerce.loginWithMagicLink(token);
|
|
188
334
|
|
|
@@ -191,8 +337,78 @@ const user = await client.commerce.loginWithMagicCode(email, code);
|
|
|
191
337
|
// Either path starts a session and fires onAuthToken — no extra token handling needed.
|
|
192
338
|
```
|
|
193
339
|
|
|
340
|
+
`portal` picks the landing page only and grants nothing: B2B portal access is still checked when
|
|
341
|
+
the link or code is consumed.
|
|
342
|
+
|
|
194
343
|
Errors thrown (via `MutationError.errorCode`): `MAGIC_LINK_INVALID_ERROR`,
|
|
195
|
-
`MAGIC_LINK_EXPIRED_ERROR`, `MAGIC_LINK_LOCKED_ERROR` (too many wrong codes)
|
|
344
|
+
`MAGIC_LINK_EXPIRED_ERROR`, `MAGIC_LINK_LOCKED_ERROR` (too many wrong codes), and, from
|
|
345
|
+
`requestMagicLink`, `MAGIC_LINK_RATE_LIMITED_ERROR` with the wait on `error.retryAfterSeconds`.
|
|
346
|
+
|
|
347
|
+
### Sign-in rate limits
|
|
348
|
+
|
|
349
|
+
Two limiters can refuse a sign-in call. Both put the wait in `error.retryAfterSeconds`, and neither
|
|
350
|
+
says whether the account exists, so the message is safe to show.
|
|
351
|
+
|
|
352
|
+
| Limiter | Calls | Thrown as |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| The store's magic-link limiter (per email, per address) | `requestMagicLink` | `MutationError`, `errorCode: 'MAGIC_LINK_RATE_LIMITED_ERROR'` |
|
|
355
|
+
| The platform's credential limiter (by default 5 a minute per email per store, plus a per-address budget) | `login`, `loginWithMagicCode`, `requestMagicLink`, `register`, `requestPasswordReset`, `refreshVerification` | `StorefrontError`, `statusCode: 429`, `code: 'RATE_LIMITED'`, `retryable: true` |
|
|
356
|
+
|
|
357
|
+
The credential limiter answers with HTTP 429 before the mutation runs, so `login` throws rather
|
|
358
|
+
than resolving to a `LoginResult`. The SDK never retries a mutation, so wait `retryAfterSeconds`
|
|
359
|
+
before letting the shopper try again:
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { StorefrontError } from '@sixtyninecommerce/client';
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await client.commerce.login(email, password);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const rateLimited =
|
|
368
|
+
error instanceof StorefrontError &&
|
|
369
|
+
(error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
|
|
370
|
+
if (!rateLimited) throw error;
|
|
371
|
+
showError(`Too many attempts. Try again in ${error.retryAfterSeconds ?? 60} seconds.`);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Server-side storefronts.** When your server calls the Shop API, every shopper shares your
|
|
376
|
+
server's address, so they all spend one per-address budget. Create the client with the head's
|
|
377
|
+
private storefront token and the shopper's address, and each shopper is metered on their own:
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// Per request, on the server only (the client refuses a private token in a browser).
|
|
381
|
+
const client = await createStorefrontClient({
|
|
382
|
+
storeCode: 'my-store',
|
|
383
|
+
cdnUrl: 'https://cdn.my-store.com',
|
|
384
|
+
privateToken: process.env.SIXTYNINE_PRIVATE_TOKEN, // s69p_…, issued with admin.heads.privateTokens.issue
|
|
385
|
+
buyerIp: shopperIp, // the address YOUR server saw the shopper connect from
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Setting a first password
|
|
390
|
+
|
|
391
|
+
A customer who signs in by link or code has no password, so `updatePassword(current, next)` cannot
|
|
392
|
+
serve them. `getCurrentCustomer()` returns `hasPassword`; when it is `false`, offer "Set password"
|
|
393
|
+
and call `setPassword`:
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
const me = await client.commerce.getCurrentCustomer();
|
|
397
|
+
if (me && !me.hasPassword) {
|
|
398
|
+
await client.commerce.setPassword(newPassword); // the account is emailed a notice
|
|
399
|
+
} else {
|
|
400
|
+
await client.commerce.updatePassword(currentPassword, newPassword);
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`setPassword` works only while the account has no password: afterwards it throws `MutationError`
|
|
405
|
+
`PASSWORD_ALREADY_SET_ERROR`. It also throws `PASSWORD_VALIDATION_ERROR` (the reason is in
|
|
406
|
+
`error.fields.validationErrorMessage`), `NATIVE_AUTH_STRATEGY_ERROR` (password sign-in is not
|
|
407
|
+
available for this account on this store) and `SET_PASSWORD_REQUIRES_EMAIL_SIGN_IN_ERROR` (the
|
|
408
|
+
session did not sign in to this store by email link or code, for example it signed in on another
|
|
409
|
+
store: have the customer sign in with a link or code again, or send them through
|
|
410
|
+
`requestPasswordReset`; `error.message` says so in words a shopper can read). The platform's
|
|
411
|
+
credential limiter meters it like `updatePassword`.
|
|
196
412
|
|
|
197
413
|
### Reference UI (storefront)
|
|
198
414
|
|
|
@@ -206,7 +422,17 @@ function MagicLinkRequest() {
|
|
|
206
422
|
|
|
207
423
|
async function request(e: React.FormEvent) {
|
|
208
424
|
e.preventDefault();
|
|
209
|
-
|
|
425
|
+
try {
|
|
426
|
+
// never reveals existence; a B2B portal passes { portal: 'B2B' }
|
|
427
|
+
await client.commerce.requestMagicLink(email.trim());
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const rateLimited =
|
|
430
|
+
error instanceof StorefrontError &&
|
|
431
|
+
(error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
|
|
432
|
+
if (!rateLimited) throw error;
|
|
433
|
+
/* show "too many requests — try again in {error.retryAfterSeconds}s" */
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
210
436
|
setStatus('sent'); // always advance — even if no account exists
|
|
211
437
|
}
|
|
212
438
|
|
|
@@ -352,6 +578,94 @@ Two language modes are transparent to the storefront — the resolver picks the
|
|
|
352
578
|
|
|
353
579
|
Visibility is gated server-side: `PUBLIC` (everyone), `AUTHENTICATED` (logged-in only), `CUSTOMER_GROUP` (allowlist of customer groups). Anonymous visitors only ever see PUBLIC docs.
|
|
354
580
|
|
|
581
|
+
## Invoices and credit notes (customer account)
|
|
582
|
+
|
|
583
|
+
A signed-in customer reads the invoices and retail credit notes of their OWN orders, by order code
|
|
584
|
+
(a credit account's accounts-receivable credit notes are not listed yet).
|
|
585
|
+
Anything else — another customer's order, a guest checkout's, another store's — is refused
|
|
586
|
+
`FORBIDDEN`, indistinguishable from an order that does not exist.
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
const invoices = await client.commerce.getMyOrderInvoices(order.code);
|
|
590
|
+
const creditNotes = await client.commerce.getMyOrderCreditNotes(order.code);
|
|
591
|
+
// creditNotes[n]: { id, creditNoteNumber, issuedOn, totalWithTax, currencyCode,
|
|
592
|
+
// kind, reason, invoiceNumber, createdAt }
|
|
593
|
+
|
|
594
|
+
// Mint on the Download click, never earlier and never cached — e.g. a route handler
|
|
595
|
+
// behind the button:
|
|
596
|
+
const { url } = await client.commerce.getMyCreditNoteDownloadUrl(creditNoteId);
|
|
597
|
+
return Response.redirect(url, 303);
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
| Field | Notes |
|
|
601
|
+
|---|---|
|
|
602
|
+
| `totalWithTax` | The credited amount, positive minor units; the PDF prints it negative |
|
|
603
|
+
| `kind` | `adjustment` (an over-charge, goods kept), `return` (a settled return), `cancellation` (the whole invoice, nothing replaces it), `correction` (the whole invoice, a corrected one replaces it) |
|
|
604
|
+
| `invoiceNumber` | The invoice the note credits. On a `cancellation` or `correction` that invoice is void and `getMyOrderInvoices` no longer lists it |
|
|
605
|
+
| `issuedOn` | ISO-8601 date |
|
|
606
|
+
|
|
607
|
+
Documents are stored privately: there is no permanent URL. `getMyInvoiceDownloadUrl` and
|
|
608
|
+
`getMyCreditNoteDownloadUrl` mint a signed link that works for five minutes (`expiresAt`) and
|
|
609
|
+
is a bearer link for that time, so hand it to the browser at once. A credit note whose PDF is not
|
|
610
|
+
stored yet answers `DOCUMENT_PDF_NOT_AVAILABLE` until the store draws it again: show the shopper
|
|
611
|
+
that the document is not available yet rather than an error.
|
|
612
|
+
|
|
613
|
+
## Receipt account claim (in-store purchases)
|
|
614
|
+
|
|
615
|
+
A store that sells at its tills can let shoppers add an anonymous in-store purchase to their
|
|
616
|
+
account from the receipt. When the store has switched it on, the receipt's page links to
|
|
617
|
+
`<storefront>/account/claim-receipt#<token>`. Build that page: read the token from the URL
|
|
618
|
+
FRAGMENT (which never reaches a server, a referrer or an analytics hit), have the shopper sign in,
|
|
619
|
+
then send it once.
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
import { receiptClaimTokenFrom } from '@sixtyninecommerce/client';
|
|
623
|
+
|
|
624
|
+
const token = receiptClaimTokenFrom(window.location); // null when the link carries none
|
|
625
|
+
if (token) {
|
|
626
|
+
const { orderCode, alreadyYours } = await client.commerce.claimReceipt(token);
|
|
627
|
+
// orderCode: the purchase, now in activeCustomer.orders; alreadyYours: nothing changed
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Only a completed till sale made at most 30 days ago on no named customer can be claimed, from a
|
|
632
|
+
live receipt link (never a gift receipt's). Every refusal answers `POS_RECEIPT_CLAIM_NOT_FOUND`
|
|
633
|
+
with one message, whatever the reason, so the page says one thing ("this receipt cannot be added
|
|
634
|
+
to an account"); a guest is `FORBIDDEN`. Treat the token as a password for the receipt: never log
|
|
635
|
+
it or send it anywhere but `claimReceipt`.
|
|
636
|
+
|
|
637
|
+
## Metafields (custom data from apps and the store)
|
|
638
|
+
|
|
639
|
+
Apps and the store can attach custom fields to products, variants and collections. The ones
|
|
640
|
+
declared `storefront: read` come back WITH the entity when you ask for them, in the same request:
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
const product = await client.commerce.getProduct('blue-shirt', { metafields: true });
|
|
644
|
+
product.metafields; // [{ namespace: 'acme-badges', key: 'badge', value: 'eco' }]
|
|
645
|
+
product.variants[0].metafields; // each variant's own
|
|
646
|
+
|
|
647
|
+
// One app's fields only (its slug is the namespace):
|
|
648
|
+
await client.commerce.getProduct('blue-shirt', { metafields: { namespace: 'acme-badges' } });
|
|
649
|
+
await client.commerce.getCollection('summer', { metafields: true }); // collection.metafields
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Also on `getProductById`, `getCollectionById`. Without the option the reads are unchanged and
|
|
653
|
+
carry no `metafields`. Fields marked `hidden`, and fields of an app that was uninstalled, never
|
|
654
|
+
reach the storefront, and a store sees only its own values on a product it shares with another.
|
|
655
|
+
`value` is whatever the field's type describes (a string, a number, money in minor units, …).
|
|
656
|
+
|
|
657
|
+
The option needs an engine that serves `metafields` on these entities; against an older engine
|
|
658
|
+
only a read that asks for them fails (a GraphQL validation error), and every other read keeps
|
|
659
|
+
working.
|
|
660
|
+
|
|
661
|
+
**Caching:** a metafield read is tagged like the plain read (`product:<id>`, `collection:<id>`,
|
|
662
|
+
`catalog`), but no platform event reports a metafield change on a product, variant or collection
|
|
663
|
+
yet. That includes a value an app writes, a field turned `hidden`, and an uninstalled app's
|
|
664
|
+
fields being withdrawn. So the event revalidation handler below never refreshes `metafields`
|
|
665
|
+
alone. A page cached by tag keeps the old values until the product itself changes or the cache
|
|
666
|
+
expires, while the engine already answers the new ones. Give metafield-bearing pages a time-based
|
|
667
|
+
revalidation if their custom fields must follow quickly.
|
|
668
|
+
|
|
355
669
|
## Next.js revalidation (platform events)
|
|
356
670
|
|
|
357
671
|
Create an event subscription (Dashboard → Settings → Event Subscriptions) pointing at
|
|
@@ -377,8 +691,12 @@ pass a wrapper such as `(tag) => revalidateTag(tag, 'max')`.
|
|
|
377
691
|
|
|
378
692
|
**Cache tags.** Product and collection reads carry `next: { tags }`, and the handler purges the
|
|
379
693
|
same tags: `product:<id>` on `getProductById` / `getProductProperties`, `collection:<id>` on
|
|
380
|
-
`getCollectionById`, `getProductsInCollectionById` and `searchProducts({ collectionId })
|
|
381
|
-
|
|
694
|
+
`getCollectionById`, `getProductsInCollectionById` and `searchProducts({ collectionId })`, and
|
|
695
|
+
`catalog` on EVERY catalogue read (products, collections, search, facets, properties,
|
|
696
|
+
recommendations) — the tag
|
|
697
|
+
the facet and price-list events purge, since neither names the products it moves. `getMenu(id)`
|
|
698
|
+
carries `menu:<id>`, which `menu.changed` purges. Load pages by the id the routes manifest
|
|
699
|
+
resolves:
|
|
382
700
|
|
|
383
701
|
```typescript
|
|
384
702
|
const route = await client.navigation.findRoute(path, locale);
|
|
@@ -393,17 +711,228 @@ if (route?.type === 'collection') {
|
|
|
393
711
|
|
|
394
712
|
`findRoute` also returns `route.cacheTags` for any other read on the page (and
|
|
395
713
|
`cacheTagsForRoute(entry)` covers `getRoutes()` entries). Raw slug reads (`getProduct(slug)`,
|
|
396
|
-
`getCollection(slug)`, `getProductsInCollection(slug)`)
|
|
714
|
+
`getCollection(slug)`, `getProductsInCollection(slug)`) carry only `catalog`, no id tag — pass
|
|
397
715
|
`{ cacheTags: route.cacheTags }` if you must use one.
|
|
398
716
|
|
|
717
|
+
What each event revalidates is `planRevalidation`'s mapping: `product.*` (including
|
|
718
|
+
`channel_assigned`/`channel_removed`) and `product_variant.*` → `product:<id>`; `collection.membership_changed` → `collection:<id>`; `facet.*` and
|
|
719
|
+
`price_list.changed` → `catalog`; `menu.changed` → `menu:<id>`; `head.changed` → `seo-artifact`
|
|
720
|
+
and a fresh URL context; `channel.renamed` → a fresh URL context only; `promotion.changed` and
|
|
721
|
+
`asset.changed` → nothing (promotions price orders, not catalogue reads; an asset edit also sends
|
|
722
|
+
`content.site_content_changed`, which revalidates the whole site); content, collection-tree,
|
|
723
|
+
URL-setting and SEO-path events → paths or the whole site. A plan with `invalidateUrlContext`
|
|
724
|
+
(URL settings, head and rename events) tells a pull-feed consumer to re-read its URL context. Any other type is answered 200
|
|
725
|
+
`skipped`.
|
|
726
|
+
|
|
727
|
+
**Several stores on one route.** Each store's subscription has its own signing secret: pass a
|
|
728
|
+
function from the store code to it, and the store router as `urls`. Each event is then planned for
|
|
729
|
+
its own store (the envelope's `channel_code`): paths are composed on that store's address and
|
|
730
|
+
handed to `toRoutePath(url, locale, store)` — map them onto your routes (e.g. a host rewritten to
|
|
731
|
+
`/[store]/…`) — and every tag is purged store-scoped, which is what the router's reads carry beside
|
|
732
|
+
the bare tags. A whole-site plan still revalidates the whole app.
|
|
733
|
+
|
|
734
|
+
```typescript
|
|
735
|
+
export const POST = createEventRevalidateHandler({
|
|
736
|
+
secret: (storeCode) => secrets[storeCode], // undefined → 500 (unset, retried), like a missing secret
|
|
737
|
+
urls: stores, // the store router
|
|
738
|
+
toRoutePath: (url, _locale, store) => `/${store}${url.pathname}`,
|
|
739
|
+
revalidatePath,
|
|
740
|
+
revalidateTag,
|
|
741
|
+
});
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
A pull-feed consumer asks `planRevalidation(envelope, urls, { storeScoped: true })` for the same
|
|
745
|
+
store-scoped tags. Only for a router's reads: a single-store client's reads carry bare tags alone.
|
|
746
|
+
|
|
399
747
|
Opt out with `createStorefrontClient({ cacheTags: false })`, or pass a resolver
|
|
400
748
|
`(tags, { operation }) => tags` to rewrite them. The SDK never sets a cache policy.
|
|
401
749
|
|
|
402
|
-
`verifyEventDelivery`, `planRevalidation` and the payload
|
|
403
|
-
`@sixtyninecommerce/client/events` for pull-feed consumers and custom handlers
|
|
750
|
+
`verifyEventDelivery`, `planRevalidation` and the payload type of EVERY platform event are exported
|
|
751
|
+
from `@sixtyninecommerce/client/events` for pull-feed consumers and custom handlers, with
|
|
752
|
+
`createEventHandler` — a verified, typed webhook endpoint for any event, beyond revalidation:
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
import { createEventHandler } from '@sixtyninecommerce/client/events';
|
|
756
|
+
|
|
757
|
+
export const POST = createEventHandler({
|
|
758
|
+
secret: process.env.EVENTS_SECRET!,
|
|
759
|
+
on: {
|
|
760
|
+
'menu.changed': async (event) => { /* event.data is MenuChangedV1 */ },
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
There is no `poll` or `stream` here: the pull feed and the live stream need an API token holding
|
|
766
|
+
`ReadPlatformEvents`, which a storefront must not hold — a server-side job that has one uses
|
|
767
|
+
`@sixtyninecommerce/admin`.
|
|
404
768
|
`createRevalidateHandler` (the legacy `/api/revalidate` SEO webhook) is deprecated and unchanged,
|
|
405
769
|
and receives nothing: the platform has retired its sender.
|
|
406
770
|
|
|
771
|
+
## Conditional reads (ETag)
|
|
772
|
+
|
|
773
|
+
Public CMS and plugins reads — menus, content strings, collections, the routes and capability
|
|
774
|
+
manifests — are remembered and revalidated: the next read of the same thing sends `If-None-Match`,
|
|
775
|
+
and an unchanged answer comes back as a bodyless 304 that the SDK answers from memory. **On by
|
|
776
|
+
default, except on Next.js's patched `fetch`** (below). The server is still asked every time, so
|
|
777
|
+
nothing is ever served stale; what you save is the payload.
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
createStorefrontClient({ storeCode, cdnUrl }); // on (off on Next's fetch): 500 reads / 5 MB per process
|
|
781
|
+
createStorefrontClient({ storeCode, cdnUrl, conditionalReads: true }); // on, Next included
|
|
782
|
+
createStorefrontClient({ storeCode, cdnUrl, conditionalReads: { maxEntries: 100 } }); // a cache of this client's own
|
|
783
|
+
createStorefrontClient({ storeCode, cdnUrl, conditionalReads: false }); // off
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
- **Never signed in.** Shop API calls (cart, checkout, account, every price) are never cached, and
|
|
787
|
+
the CMS/plugins reads never carry the shopper's session — they are the same request for a
|
|
788
|
+
signed-in shopper as for a guest.
|
|
789
|
+
- **Never across stores, languages or tokens.** The cache key is everything the request sends: the
|
|
790
|
+
store, the language, the private storefront token, the query.
|
|
791
|
+
- **Process-wide.** Every client on the same `fetch` shares one cache, so a client built per request
|
|
792
|
+
still benefits. Identical reads in flight at the same moment share one request, and each caller
|
|
793
|
+
gets its own copy of the answer. Errors are never stored.
|
|
794
|
+
- **Next.js: off by default.** On Next's patched `fetch` (it carries `__nextPatched`) the default
|
|
795
|
+
is `false`. The SDK cache sits above that `fetch`, and Next's data cache lives inside it: Next keys
|
|
796
|
+
a cached fetch on its request headers and stores only 200s, so an added `If-None-Match` would turn
|
|
797
|
+
Next's zero-request cache hits into 304 round trips. An app that does not cache its fetches with
|
|
798
|
+
Next (no `cache: 'force-cache'`, segment `revalidate` or `fetchCache`) can pass
|
|
799
|
+
`conditionalReads: true`. A wrapper around Next's `fetch` hides the marker: pass `false` then.
|
|
800
|
+
|
|
801
|
+
## Product Feeds
|
|
802
|
+
|
|
803
|
+
The platform renders one RSS 2.0 product feed per configured destination (Google Shopping, Meta, Pinterest, TikTok) and serves it at a token-guarded public URL. `client.feeds.feedUrl(token)` builds that URL — the token is the feed's shared secret (Dashboard → Marketing → Product feeds), so treat the URL as unlisted.
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
client.feeds.feedUrl('9f3c…'); // https://plugins.example.com/feeds/megamart/9f3c….xml
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
You can hand that platform URL to a merchant centre directly. To serve the feed from the storefront's **own** origin instead, proxy it with `createProductFeedHandler` — the same fetch-and-serve shape as `createSeoArtifactHandler`, so it works on any hosting adapter (unlike `next.config` rewrites):
|
|
810
|
+
|
|
811
|
+
```typescript
|
|
812
|
+
// src/app/product-feed.xml/route.ts — one fixed feed
|
|
813
|
+
import { createProductFeedHandler } from '@sixtyninecommerce/client/next';
|
|
814
|
+
|
|
815
|
+
export const GET = createProductFeedHandler(getClient(), {
|
|
816
|
+
token: process.env.GOOGLE_FEED_TOKEN, // server-only
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// src/app/feeds/[token]/route.ts — several feeds off one route
|
|
820
|
+
export const GET = createProductFeedHandler(getClient());
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
| Behavior | Notes |
|
|
824
|
+
|---|---|
|
|
825
|
+
| Caching | `public, max-age=300, s-maxage=1800, stale-while-revalidate=86400` by default — override with `cacheControl` |
|
|
826
|
+
| `ETag` / `If-None-Match` | Forwarded upstream and passed back, so an unchanged feed answers `304` |
|
|
827
|
+
| Body | Buffered before the `200` goes out, so a timeout mid-transfer is a clean `503`, never a truncated feed a CDN would cache |
|
|
828
|
+
| User-Agent | The inbound crawler UA is **not** forwarded; the platform must see your server |
|
|
829
|
+
| `X-Robots-Tag` | Dropped from the upstream response |
|
|
830
|
+
| Failures | Upstream `404` → `404`; anything else (or a timeout, default 30s) → `503` with `Retry-After: 60` |
|
|
831
|
+
|
|
832
|
+
## SEO artifacts, feeds and redirects on any framework (`/web`)
|
|
833
|
+
|
|
834
|
+
`@sixtyninecommerce/client/web` serves the platform-rendered sitemap, robots.txt, llms.txt,
|
|
835
|
+
IndexNow key and product feeds on the storefront's own origin, and applies the store's redirects,
|
|
836
|
+
from any server that speaks the Fetch API — `(request: Request) => Promise<Response>`, no framework
|
|
837
|
+
imports. The `/next` handlers are these same responders plus Next's cache tag, so the statuses,
|
|
838
|
+
headers and bodies are identical. Each takes a store router in place of a client too
|
|
839
|
+
([several stores in one process](#several-stores-in-one-process)):
|
|
840
|
+
|
|
841
|
+
| Behavior | Notes |
|
|
842
|
+
|---|---|
|
|
843
|
+
| `ETag` / `If-None-Match` | Forwarded upstream and passed back; an unchanged file answers `304` with its `ETag` |
|
|
844
|
+
| Caching | The storefront's `Cache-Control` (`cacheControl` option); the platform's is replaced |
|
|
845
|
+
| `X-Robots-Tag` | Dropped from the upstream response |
|
|
846
|
+
| User-Agent | The inbound crawler UA is **not** forwarded; the platform must see your server |
|
|
847
|
+
| Credentials | None sent upstream — the files are public; a private storefront token never leaves the server |
|
|
848
|
+
| Child sitemaps | A name the platform could never serve (`<kind>-<n>.xml`) is a `404`, without a request |
|
|
849
|
+
| Failures | Upstream `404` → `404`; anything else (or the timeout: 15s, feeds 30s) → `503` + `Retry-After: 60` |
|
|
850
|
+
| `fetch` | The one you configured the client with (the `/next` handlers use Next's patched global) |
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
import {
|
|
854
|
+
createProductFeedResponder,
|
|
855
|
+
createRedirectMiddleware,
|
|
856
|
+
createSeoArtifactResponder,
|
|
857
|
+
} from '@sixtyninecommerce/client/web';
|
|
858
|
+
|
|
859
|
+
const client = getClient(); // a client or a client promise, built on the server
|
|
860
|
+
const sitemap = createSeoArtifactResponder(client, 'sitemap');
|
|
861
|
+
const sitemapChild = createSeoArtifactResponder(client, 'sitemap-child'); // (request, { name })
|
|
862
|
+
const robots = createSeoArtifactResponder(client, 'robots');
|
|
863
|
+
const llmsTxt = createSeoArtifactResponder(client, 'llms-txt');
|
|
864
|
+
const feed = createProductFeedResponder(client, { token: process.env.GOOGLE_FEED_TOKEN }); // server-only
|
|
865
|
+
const redirects = createRedirectMiddleware({ store: (await client).redirects }); // Response | undefined
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
Plain `node:http`:
|
|
869
|
+
|
|
870
|
+
```typescript
|
|
871
|
+
import { createServer } from 'node:http';
|
|
872
|
+
|
|
873
|
+
createServer(async (req, res) => {
|
|
874
|
+
const request = new Request(new URL(req.url ?? '/', `https://${req.headers.host}`), {
|
|
875
|
+
headers: req.headers as Record<string, string>,
|
|
876
|
+
});
|
|
877
|
+
const { pathname } = new URL(request.url);
|
|
878
|
+
const response =
|
|
879
|
+
(await redirects(request)) ??
|
|
880
|
+
(pathname === '/sitemap.xml' ? await sitemap(request)
|
|
881
|
+
: pathname.startsWith('/sitemaps/') ? await sitemapChild(request, { name: pathname.slice(10) })
|
|
882
|
+
: pathname === '/robots.txt' ? await robots(request)
|
|
883
|
+
: pathname === '/llms.txt' ? await llmsTxt(request)
|
|
884
|
+
: new Response('Not found', { status: 404 }));
|
|
885
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
886
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
887
|
+
}).listen(3000);
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
Remix / React Router (resource routes):
|
|
891
|
+
|
|
892
|
+
```typescript
|
|
893
|
+
// app/routes/sitemap[.]xml.ts
|
|
894
|
+
export const loader = ({ request }: LoaderFunctionArgs) => sitemap(request);
|
|
895
|
+
// app/routes/sitemaps.$name.ts
|
|
896
|
+
export const loader = ({ request, params }: LoaderFunctionArgs) => sitemapChild(request, params);
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Astro (endpoints):
|
|
900
|
+
|
|
901
|
+
```typescript
|
|
902
|
+
// src/pages/robots.txt.ts
|
|
903
|
+
export const GET: APIRoute = ({ request }) => robots(request);
|
|
904
|
+
// src/pages/sitemaps/[name].ts
|
|
905
|
+
export const GET: APIRoute = ({ request, params }) => sitemapChild(request, params);
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
SvelteKit (`+server.ts` and the `handle` hook):
|
|
909
|
+
|
|
910
|
+
```typescript
|
|
911
|
+
// src/routes/llms.txt/+server.ts
|
|
912
|
+
export const GET: RequestHandler = ({ request }) => llmsTxt(request);
|
|
913
|
+
// src/hooks.server.ts
|
|
914
|
+
export const handle: Handle = async ({ event, resolve }) =>
|
|
915
|
+
(await redirects(event.request)) ?? resolve(event);
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
Hono / Cloudflare Workers:
|
|
919
|
+
|
|
920
|
+
```typescript
|
|
921
|
+
app.use(async (c, next) => (await redirects(c.req.raw)) ?? next());
|
|
922
|
+
app.get('/sitemap.xml', (c) => sitemap(c.req.raw));
|
|
923
|
+
app.get('/sitemaps/:name', (c) => sitemapChild(c.req.raw, { name: c.req.param('name') }));
|
|
924
|
+
app.get('/product-feed.xml', (c) => feed(c.req.raw));
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
To read an artifact yourself, `client.seo.artifacts.fetch(artifact, { childFile?, ifNoneMatch?,
|
|
928
|
+
signal?, timeoutMs? })` answers `{ status: 200, body, contentType, etag }`, `{ status: 304, etag }`,
|
|
929
|
+
`{ status: 404 }` or `{ status: 503, retryAfterSeconds, error? }` — it never throws for an upstream
|
|
930
|
+
problem, and throws `INVALID_ARGUMENT` for an unknown artifact or a bad `childFile`.
|
|
931
|
+
|
|
932
|
+
There is no data cache in `/web`: your CDN caches per the `Cache-Control` above. When an event
|
|
933
|
+
plan from `planRevalidation` carries the `seo-artifact` tag (`head.changed`, and every plan that
|
|
934
|
+
revalidates anything), purge your CDN's copies of these paths.
|
|
935
|
+
|
|
407
936
|
## Tree-Shakeable Imports
|
|
408
937
|
|
|
409
938
|
```typescript
|
|
@@ -414,6 +943,26 @@ import { formatPrice, getTierForQuantity } from '@sixtyninecommerce/client/prici
|
|
|
414
943
|
import { PRESETS, getResponsiveImageData } from '@sixtyninecommerce/client/media';
|
|
415
944
|
```
|
|
416
945
|
|
|
946
|
+
## Generated types
|
|
947
|
+
|
|
948
|
+
The catalogue types are generated from the Shop API schema and the SDK's own queries, so they
|
|
949
|
+
describe exactly what each method returns: `ProductVariantFragment`, `ProductSummaryFragment`,
|
|
950
|
+
`CollectionDetailFragment`, `SearchResultFragment`, … A shape the SDK transforms keeps an SDK name
|
|
951
|
+
derived from the generated one (`Product` is `ProductFragment` with `metadata` parsed).
|
|
952
|
+
|
|
953
|
+
```typescript
|
|
954
|
+
import type { Product, ProductVariantFragment } from '@sixtyninecommerce/client';
|
|
955
|
+
|
|
956
|
+
// Every generated type — operations, fragments, the schema's enums and inputs — type-only:
|
|
957
|
+
import type { CurrencyCode, GetProductQuery } from '@sixtyninecommerce/client/types';
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
`getProducts` types its rows from the options: `ProductSummaryFragment` by default,
|
|
961
|
+
`ProductCardFragment` with `{ detail: 'card' }`.
|
|
962
|
+
|
|
963
|
+
Upgrading from 0.30: the rename table and every changed field are in
|
|
964
|
+
[docs/storefront/generated-types.md](https://github.com/SIXTYNINE-digital/sixtynine-commerce-engine/blob/main/docs/storefront/generated-types.md).
|
|
965
|
+
|
|
417
966
|
## Error Handling
|
|
418
967
|
|
|
419
968
|
Every error this SDK throws is a `StorefrontError`, and every `StorefrontError` is a
|
|
@@ -439,7 +988,16 @@ try {
|
|
|
439
988
|
// error.code / error.status — the platform code and HTTP status
|
|
440
989
|
// error.statusCode — the same status, `undefined` when there was none
|
|
441
990
|
// error.details — [{ field?, code, message }]
|
|
442
|
-
// error.
|
|
991
|
+
// error.retryable — true for a 429 and a 502/503/504 (an outage:
|
|
992
|
+
// code SERVICE_UNAVAILABLE when the engine's
|
|
993
|
+
// database is down); false for a 500 fault
|
|
994
|
+
// error.outcomeUnknown — true when the write may or may not have
|
|
995
|
+
// landed (never retryable): the engine said so,
|
|
996
|
+
// or the mutation timed out / was aborted by your
|
|
997
|
+
// signal after it was sent / lost its connection
|
|
998
|
+
// / got a 5xx nobody coded. Re-read the cart or
|
|
999
|
+
// order before trying again. Never set on a query.
|
|
1000
|
+
// error.retryAfterSeconds — the wait hint, on a 429 or a 503
|
|
443
1001
|
// error.traceId — W3C trace id, on failures the service ANSWERED
|
|
444
1002
|
// (not on a timeout, abort or network failure)
|
|
445
1003
|
// error.problem — the RFC 9457 body, when the service sent one
|
|
@@ -456,3 +1014,5 @@ try {
|
|
|
456
1014
|
## Subscription purchases and management
|
|
457
1015
|
|
|
458
1016
|
Use `previewSubscriptionPurchase` before `startSubscriptionPurchase`, then poll `getSubscriptionPurchaseStatus` after provider-hosted checkout. Customer lifecycle changes also have a preview: `previewMySubscriptionChange` followed by `manageMySubscription` with the same persisted request key. See the [subscription integration guide](../../docs/integrations/subscriptions.md) for payment recovery, trials, cancellation commitments, allowed actions, and compatibility.
|
|
1017
|
+
|
|
1018
|
+
Every refusal the subscriptions plugin makes is a `StorefrontError` with a `SUBSCRIPTION_*` `code` (each method's TSDoc lists its codes; the guide has what to do about each) and the engine's sentence. Branch on `code`, never on the sentence. A method that needs a signed-in shopper answers a signed-out session with the platform's `FORBIDDEN` before the plugin runs; `SUBSCRIPTION_GUEST_NOT_ALLOWED` means signed in but with no customer in this store. An engine before these codes answered all of them as `USER_INPUT_ERROR` (400) with the same sentence.
|