@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.
Files changed (361) hide show
  1. package/README.md +574 -14
  2. package/dist/admin/http.d.ts.map +1 -1
  3. package/dist/admin/http.js.map +1 -1
  4. package/dist/admin/types.d.ts.map +1 -1
  5. package/dist/build-client.d.ts +9 -0
  6. package/dist/build-client.d.ts.map +1 -0
  7. package/dist/build-client.js +58 -0
  8. package/dist/build-client.js.map +1 -0
  9. package/dist/cache-tags.d.ts +35 -1
  10. package/dist/cache-tags.d.ts.map +1 -1
  11. package/dist/cache-tags.js +39 -1
  12. package/dist/cache-tags.js.map +1 -1
  13. package/dist/client.d.ts +25 -0
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +9 -62
  16. package/dist/client.js.map +1 -1
  17. package/dist/commerce/age-verification.d.ts +7 -4
  18. package/dist/commerce/age-verification.d.ts.map +1 -1
  19. package/dist/commerce/age-verification.js +6 -4
  20. package/dist/commerce/age-verification.js.map +1 -1
  21. package/dist/commerce/arguments.d.ts +13 -0
  22. package/dist/commerce/arguments.d.ts.map +1 -0
  23. package/dist/commerce/arguments.js +19 -0
  24. package/dist/commerce/arguments.js.map +1 -0
  25. package/dist/commerce/branding.d.ts.map +1 -1
  26. package/dist/commerce/branding.js.map +1 -1
  27. package/dist/commerce/capabilities.d.ts +40 -0
  28. package/dist/commerce/capabilities.d.ts.map +1 -0
  29. package/dist/commerce/capabilities.js +41 -0
  30. package/dist/commerce/capabilities.js.map +1 -0
  31. package/dist/commerce/cart.d.ts +9 -12
  32. package/dist/commerce/cart.d.ts.map +1 -1
  33. package/dist/commerce/cart.js +6 -1
  34. package/dist/commerce/cart.js.map +1 -1
  35. package/dist/commerce/checkout.d.ts +95 -15
  36. package/dist/commerce/checkout.d.ts.map +1 -1
  37. package/dist/commerce/checkout.js +230 -10
  38. package/dist/commerce/checkout.js.map +1 -1
  39. package/dist/commerce/customer.d.ts +83 -38
  40. package/dist/commerce/customer.d.ts.map +1 -1
  41. package/dist/commerce/customer.js +149 -10
  42. package/dist/commerce/customer.js.map +1 -1
  43. package/dist/commerce/favorites.d.ts +2 -5
  44. package/dist/commerce/favorites.d.ts.map +1 -1
  45. package/dist/commerce/favorites.js.map +1 -1
  46. package/dist/commerce/index.d.ts +194 -133
  47. package/dist/commerce/index.d.ts.map +1 -1
  48. package/dist/commerce/index.js +15 -0
  49. package/dist/commerce/index.js.map +1 -1
  50. package/dist/commerce/order-states.d.ts +18 -0
  51. package/dist/commerce/order-states.d.ts.map +1 -0
  52. package/dist/commerce/order-states.js +30 -0
  53. package/dist/commerce/order-states.js.map +1 -0
  54. package/dist/commerce/parcel-pricing.d.ts +48 -0
  55. package/dist/commerce/parcel-pricing.d.ts.map +1 -0
  56. package/dist/commerce/parcel-pricing.js +39 -0
  57. package/dist/commerce/parcel-pricing.js.map +1 -0
  58. package/dist/commerce/parked-orders.d.ts +22 -0
  59. package/dist/commerce/parked-orders.d.ts.map +1 -0
  60. package/dist/commerce/parked-orders.js +47 -0
  61. package/dist/commerce/parked-orders.js.map +1 -0
  62. package/dist/commerce/payments.d.ts +12 -5
  63. package/dist/commerce/payments.d.ts.map +1 -1
  64. package/dist/commerce/payments.js +10 -1
  65. package/dist/commerce/payments.js.map +1 -1
  66. package/dist/commerce/placement-error.d.ts +17 -0
  67. package/dist/commerce/placement-error.d.ts.map +1 -1
  68. package/dist/commerce/placement-error.js.map +1 -1
  69. package/dist/commerce/products.d.ts +92 -38
  70. package/dist/commerce/products.d.ts.map +1 -1
  71. package/dist/commerce/products.js +157 -123
  72. package/dist/commerce/products.js.map +1 -1
  73. package/dist/commerce/queries.d.ts +216 -49
  74. package/dist/commerce/queries.d.ts.map +1 -1
  75. package/dist/commerce/queries.js +1347 -743
  76. package/dist/commerce/queries.js.map +1 -1
  77. package/dist/commerce/quotes.d.ts +5 -4
  78. package/dist/commerce/quotes.d.ts.map +1 -1
  79. package/dist/commerce/quotes.js +1 -36
  80. package/dist/commerce/quotes.js.map +1 -1
  81. package/dist/commerce/receipt-claim.d.ts +35 -0
  82. package/dist/commerce/receipt-claim.d.ts.map +1 -0
  83. package/dist/commerce/receipt-claim.js +66 -0
  84. package/dist/commerce/receipt-claim.js.map +1 -0
  85. package/dist/commerce/recommendations.d.ts +3 -3
  86. package/dist/commerce/recommendations.d.ts.map +1 -1
  87. package/dist/commerce/recommendations.js +3 -1
  88. package/dist/commerce/recommendations.js.map +1 -1
  89. package/dist/commerce/returns.d.ts +7 -6
  90. package/dist/commerce/returns.d.ts.map +1 -1
  91. package/dist/commerce/returns.js +24 -3
  92. package/dist/commerce/returns.js.map +1 -1
  93. package/dist/commerce/reviews.d.ts +24 -15
  94. package/dist/commerce/reviews.d.ts.map +1 -1
  95. package/dist/commerce/reviews.js +67 -50
  96. package/dist/commerce/reviews.js.map +1 -1
  97. package/dist/commerce/saved-lists.d.ts +15 -15
  98. package/dist/commerce/saved-lists.d.ts.map +1 -1
  99. package/dist/commerce/saved-lists.js +67 -56
  100. package/dist/commerce/saved-lists.js.map +1 -1
  101. package/dist/commerce/search.d.ts +3 -2
  102. package/dist/commerce/search.d.ts.map +1 -1
  103. package/dist/commerce/search.js +2 -2
  104. package/dist/commerce/search.js.map +1 -1
  105. package/dist/commerce/shipping.d.ts +11 -4
  106. package/dist/commerce/shipping.d.ts.map +1 -1
  107. package/dist/commerce/shipping.js +17 -2
  108. package/dist/commerce/shipping.js.map +1 -1
  109. package/dist/commerce/stock-notifications.d.ts +2 -0
  110. package/dist/commerce/stock-notifications.d.ts.map +1 -1
  111. package/dist/commerce/stock-notifications.js +2 -2
  112. package/dist/commerce/stock-notifications.js.map +1 -1
  113. package/dist/commerce/store-pickup.d.ts +37 -0
  114. package/dist/commerce/store-pickup.d.ts.map +1 -0
  115. package/dist/commerce/store-pickup.js +66 -0
  116. package/dist/commerce/store-pickup.js.map +1 -0
  117. package/dist/commerce/subscriptions.d.ts +35 -37
  118. package/dist/commerce/subscriptions.d.ts.map +1 -1
  119. package/dist/commerce/subscriptions.js +159 -120
  120. package/dist/commerce/subscriptions.js.map +1 -1
  121. package/dist/commerce/vat-validation.d.ts +6 -3
  122. package/dist/commerce/vat-validation.d.ts.map +1 -1
  123. package/dist/commerce/vat-validation.js +24 -19
  124. package/dist/commerce/vat-validation.js.map +1 -1
  125. package/dist/constants.d.ts.map +1 -1
  126. package/dist/constants.js.map +1 -1
  127. package/dist/content/assets.d.ts +5 -4
  128. package/dist/content/assets.d.ts.map +1 -1
  129. package/dist/content/assets.js.map +1 -1
  130. package/dist/content/collection-pages.d.ts +3 -2
  131. package/dist/content/collection-pages.d.ts.map +1 -1
  132. package/dist/content/collection-pages.js.map +1 -1
  133. package/dist/content/collections.d.ts +5 -4
  134. package/dist/content/collections.d.ts.map +1 -1
  135. package/dist/content/collections.js.map +1 -1
  136. package/dist/content/filtered-products.d.ts.map +1 -1
  137. package/dist/content/filtered-products.js.map +1 -1
  138. package/dist/content/form-field-types.d.ts +3 -3
  139. package/dist/content/form-field-types.d.ts.map +1 -1
  140. package/dist/content/form-field-types.js.map +1 -1
  141. package/dist/content/forms.d.ts +4 -3
  142. package/dist/content/forms.d.ts.map +1 -1
  143. package/dist/content/forms.js +3 -0
  144. package/dist/content/forms.js.map +1 -1
  145. package/dist/content/index.d.ts +18 -17
  146. package/dist/content/index.d.ts.map +1 -1
  147. package/dist/content/items.d.ts +5 -4
  148. package/dist/content/items.d.ts.map +1 -1
  149. package/dist/content/items.js +2 -0
  150. package/dist/content/items.js.map +1 -1
  151. package/dist/content/menus.d.ts +3 -2
  152. package/dist/content/menus.d.ts.map +1 -1
  153. package/dist/content/menus.js +7 -1
  154. package/dist/content/menus.js.map +1 -1
  155. package/dist/content/resolve.d.ts +3 -2
  156. package/dist/content/resolve.d.ts.map +1 -1
  157. package/dist/content/resolve.js +1 -1
  158. package/dist/content/resolve.js.map +1 -1
  159. package/dist/content/sitemap.d.ts +3 -2
  160. package/dist/content/sitemap.d.ts.map +1 -1
  161. package/dist/content/sitemap.js.map +1 -1
  162. package/dist/content/strings.d.ts +16 -2
  163. package/dist/content/strings.d.ts.map +1 -1
  164. package/dist/content/strings.js +32 -4
  165. package/dist/content/strings.js.map +1 -1
  166. package/dist/core/context.d.ts +127 -1
  167. package/dist/core/context.d.ts.map +1 -1
  168. package/dist/core/context.js +436 -45
  169. package/dist/core/context.js.map +1 -1
  170. package/dist/core/currency.d.ts +14 -0
  171. package/dist/core/currency.d.ts.map +1 -0
  172. package/dist/core/currency.js +52 -0
  173. package/dist/core/currency.js.map +1 -0
  174. package/dist/core/deprecation.d.ts +23 -0
  175. package/dist/core/deprecation.d.ts.map +1 -0
  176. package/dist/core/deprecation.js +81 -0
  177. package/dist/core/deprecation.js.map +1 -0
  178. package/dist/core/http.d.ts +15 -1
  179. package/dist/core/http.d.ts.map +1 -1
  180. package/dist/core/http.js +55 -3
  181. package/dist/core/http.js.map +1 -1
  182. package/dist/core/public-artifact.d.ts +72 -0
  183. package/dist/core/public-artifact.d.ts.map +1 -0
  184. package/dist/core/public-artifact.js +122 -0
  185. package/dist/core/public-artifact.js.map +1 -0
  186. package/dist/core/unknown-field-rejection.d.ts +18 -0
  187. package/dist/core/unknown-field-rejection.d.ts.map +1 -0
  188. package/dist/core/unknown-field-rejection.js +37 -0
  189. package/dist/core/unknown-field-rejection.js.map +1 -0
  190. package/dist/credit/credit-status.d.ts +2 -0
  191. package/dist/credit/credit-status.d.ts.map +1 -1
  192. package/dist/credit/credit-status.js +71 -19
  193. package/dist/credit/credit-status.js.map +1 -1
  194. package/dist/credit/index.d.ts +1 -1
  195. package/dist/credit/payment-terms.d.ts +3 -3
  196. package/dist/credit/payment-terms.d.ts.map +1 -1
  197. package/dist/credit/payment-terms.js +28 -24
  198. package/dist/credit/payment-terms.js.map +1 -1
  199. package/dist/currency/exchange.d.ts +7 -7
  200. package/dist/currency/exchange.d.ts.map +1 -1
  201. package/dist/currency/exchange.js +5 -1
  202. package/dist/currency/exchange.js.map +1 -1
  203. package/dist/errors.d.ts +17 -0
  204. package/dist/errors.d.ts.map +1 -1
  205. package/dist/errors.js +13 -0
  206. package/dist/errors.js.map +1 -1
  207. package/dist/events/index.d.ts +20 -7
  208. package/dist/events/index.d.ts.map +1 -1
  209. package/dist/events/index.js +17 -5
  210. package/dist/events/index.js.map +1 -1
  211. package/dist/events/payloads.d.ts +7 -4
  212. package/dist/events/payloads.d.ts.map +1 -1
  213. package/dist/events/payloads.js +6 -3
  214. package/dist/events/payloads.js.map +1 -1
  215. package/dist/feeds/index.d.ts +5 -0
  216. package/dist/feeds/index.d.ts.map +1 -0
  217. package/dist/feeds/index.js +12 -0
  218. package/dist/feeds/index.js.map +1 -0
  219. package/dist/feeds/types.d.ts +18 -0
  220. package/dist/feeds/types.d.ts.map +1 -0
  221. package/dist/feeds/types.js +8 -0
  222. package/dist/feeds/types.js.map +1 -0
  223. package/dist/generated/cms.d.ts +2136 -0
  224. package/dist/generated/cms.d.ts.map +1 -0
  225. package/dist/generated/cms.js +4 -0
  226. package/dist/generated/cms.js.map +1 -0
  227. package/dist/generated/plugins.d.ts +550 -0
  228. package/dist/generated/plugins.d.ts.map +1 -0
  229. package/dist/generated/plugins.js +4 -0
  230. package/dist/generated/plugins.js.map +1 -0
  231. package/dist/generated/shop.d.ts +5663 -0
  232. package/dist/generated/shop.d.ts.map +1 -0
  233. package/dist/generated/shop.js +5 -0
  234. package/dist/generated/shop.js.map +1 -0
  235. package/dist/graphql-document.d.ts +29 -0
  236. package/dist/graphql-document.d.ts.map +1 -0
  237. package/dist/graphql-document.js +21 -0
  238. package/dist/graphql-document.js.map +1 -0
  239. package/dist/index.d.ts +10 -4
  240. package/dist/index.d.ts.map +1 -1
  241. package/dist/index.js +5 -2
  242. package/dist/index.js.map +1 -1
  243. package/dist/media/urls.d.ts.map +1 -1
  244. package/dist/media/urls.js +12 -1
  245. package/dist/media/urls.js.map +1 -1
  246. package/dist/media/video.d.ts.map +1 -1
  247. package/dist/media/video.js.map +1 -1
  248. package/dist/multi-store.d.ts +200 -0
  249. package/dist/multi-store.d.ts.map +1 -0
  250. package/dist/multi-store.js +558 -0
  251. package/dist/multi-store.js.map +1 -0
  252. package/dist/navigation/routes.d.ts +4 -3
  253. package/dist/navigation/routes.d.ts.map +1 -1
  254. package/dist/navigation/routes.js.map +1 -1
  255. package/dist/next/artifact-handler.d.ts +21 -13
  256. package/dist/next/artifact-handler.d.ts.map +1 -1
  257. package/dist/next/artifact-handler.js +28 -66
  258. package/dist/next/artifact-handler.js.map +1 -1
  259. package/dist/next/event-revalidate-handler.d.ts +26 -6
  260. package/dist/next/event-revalidate-handler.d.ts.map +1 -1
  261. package/dist/next/event-revalidate-handler.js +126 -19
  262. package/dist/next/event-revalidate-handler.js.map +1 -1
  263. package/dist/next/feed-handler.d.ts +50 -0
  264. package/dist/next/feed-handler.d.ts.map +1 -0
  265. package/dist/next/feed-handler.js +61 -0
  266. package/dist/next/feed-handler.js.map +1 -0
  267. package/dist/next/index.d.ts +3 -1
  268. package/dist/next/index.d.ts.map +1 -1
  269. package/dist/next/index.js +1 -0
  270. package/dist/next/index.js.map +1 -1
  271. package/dist/next/metadata.d.ts.map +1 -1
  272. package/dist/next/metadata.js.map +1 -1
  273. package/dist/next/redirect-middleware.d.ts +5 -31
  274. package/dist/next/redirect-middleware.d.ts.map +1 -1
  275. package/dist/next/redirect-middleware.js +4 -46
  276. package/dist/next/redirect-middleware.js.map +1 -1
  277. package/dist/next/revalidate-handler.d.ts.map +1 -1
  278. package/dist/next/revalidate-handler.js.map +1 -1
  279. package/dist/pricing/index.d.ts.map +1 -1
  280. package/dist/pricing/index.js.map +1 -1
  281. package/dist/pricing/tax-display.js +1 -1
  282. package/dist/pricing/tax-display.js.map +1 -1
  283. package/dist/pricing/tiers.d.ts +3 -2
  284. package/dist/pricing/tiers.d.ts.map +1 -1
  285. package/dist/pricing/tiers.js.map +1 -1
  286. package/dist/pricing/validation.d.ts +88 -12
  287. package/dist/pricing/validation.d.ts.map +1 -1
  288. package/dist/pricing/validation.js +93 -12
  289. package/dist/pricing/validation.js.map +1 -1
  290. package/dist/redirects/index.d.ts.map +1 -1
  291. package/dist/redirects/index.js +2 -1
  292. package/dist/redirects/index.js.map +1 -1
  293. package/dist/redirects/store.d.ts.map +1 -1
  294. package/dist/redirects/store.js +6 -2
  295. package/dist/redirects/store.js.map +1 -1
  296. package/dist/redirects/types.d.ts +5 -2
  297. package/dist/redirects/types.d.ts.map +1 -1
  298. package/dist/revalidation/event-plan.d.ts +55 -4
  299. package/dist/revalidation/event-plan.d.ts.map +1 -1
  300. package/dist/revalidation/event-plan.js +115 -8
  301. package/dist/revalidation/event-plan.js.map +1 -1
  302. package/dist/revalidation/event-verify.d.ts +8 -37
  303. package/dist/revalidation/event-verify.d.ts.map +1 -1
  304. package/dist/revalidation/event-verify.js +7 -97
  305. package/dist/revalidation/event-verify.js.map +1 -1
  306. package/dist/revalidation/types.d.ts.map +1 -1
  307. package/dist/revalidation/url-context.d.ts +17 -0
  308. package/dist/revalidation/url-context.d.ts.map +1 -1
  309. package/dist/revalidation/url-context.js +35 -2
  310. package/dist/revalidation/url-context.js.map +1 -1
  311. package/dist/seo/ai.d.ts.map +1 -1
  312. package/dist/seo/ai.js.map +1 -1
  313. package/dist/seo/artifacts.d.ts +86 -4
  314. package/dist/seo/artifacts.d.ts.map +1 -1
  315. package/dist/seo/artifacts.js +115 -16
  316. package/dist/seo/artifacts.js.map +1 -1
  317. package/dist/seo/availability.d.ts +2 -2
  318. package/dist/seo/availability.d.ts.map +1 -1
  319. package/dist/seo/availability.js.map +1 -1
  320. package/dist/seo/index.d.ts +2 -0
  321. package/dist/seo/index.d.ts.map +1 -1
  322. package/dist/seo/index.js +3 -1
  323. package/dist/seo/index.js.map +1 -1
  324. package/dist/seo/json-ld.d.ts.map +1 -1
  325. package/dist/seo/json-ld.js.map +1 -1
  326. package/dist/seo/meta-tags.d.ts.map +1 -1
  327. package/dist/seo/meta-tags.js.map +1 -1
  328. package/dist/seo/metadata-parser.d.ts.map +1 -1
  329. package/dist/seo/metadata-parser.js.map +1 -1
  330. package/dist/seo/metadata-types.d.ts.map +1 -1
  331. package/dist/seo/robots.d.ts.map +1 -1
  332. package/dist/seo/robots.js.map +1 -1
  333. package/dist/seo/types.d.ts +9 -2
  334. package/dist/seo/types.d.ts.map +1 -1
  335. package/dist/seo/url-builder.d.ts +5 -3
  336. package/dist/seo/url-builder.d.ts.map +1 -1
  337. package/dist/seo/url-builder.js +3 -1
  338. package/dist/seo/url-builder.js.map +1 -1
  339. package/dist/types.d.ts +1381 -2537
  340. package/dist/types.d.ts.map +1 -1
  341. package/dist/web/artifact-responder.d.ts +69 -0
  342. package/dist/web/artifact-responder.d.ts.map +1 -0
  343. package/dist/web/artifact-responder.js +90 -0
  344. package/dist/web/artifact-responder.js.map +1 -0
  345. package/dist/web/feed-responder.d.ts +72 -0
  346. package/dist/web/feed-responder.d.ts.map +1 -0
  347. package/dist/web/feed-responder.js +81 -0
  348. package/dist/web/feed-responder.js.map +1 -0
  349. package/dist/web/index.d.ts +23 -0
  350. package/dist/web/index.d.ts.map +1 -0
  351. package/dist/web/index.js +19 -0
  352. package/dist/web/index.js.map +1 -0
  353. package/dist/web/redirect-middleware.d.ts +76 -0
  354. package/dist/web/redirect-middleware.d.ts.map +1 -0
  355. package/dist/web/redirect-middleware.js +94 -0
  356. package/dist/web/redirect-middleware.js.map +1 -0
  357. package/dist/web/respond.d.ts +20 -0
  358. package/dist/web/respond.d.ts.map +1 -0
  359. package/dist/web/respond.js +52 -0
  360. package/dist/web/respond.js.map +1 -0
  361. 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, { minOrderAmount: 5000 });
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
- // Escape hatch for custom queries
140
- const data = await client.graphql(`query { activeChannel { id } }`);
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 — always resolves (anti-enumeration). Show "check your inbox" regardless.
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
- await client.commerce.requestMagicLink(email.trim()); // never reveals existence
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 })`. Load
381
- pages by the id the routes manifest resolves:
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)`) stay untagged — pass
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 types are exported from
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.retryAfterSeconds — set on a 429
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.