@shopify/hydrogen 2026.10.0-preview.2 → 2026.10.0-preview.4

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 (404) hide show
  1. package/README.md +34 -3
  2. package/dist/cli/index.mjs +578 -85
  3. package/dist/client/client.mjs +2 -2
  4. package/dist/client/errors.d.mts +19 -19
  5. package/dist/client/errors.mjs +19 -16
  6. package/dist/client/errors.mjs.map +1 -1
  7. package/dist/client/types.d.mts +91 -10
  8. package/dist/core/analytics/cart-tracker.d.mts +14 -0
  9. package/dist/core/analytics/cart-tracker.mjs +15 -1
  10. package/dist/core/analytics/cart-tracker.mjs.map +1 -1
  11. package/dist/core/analytics/custom-consent.mjs +12 -0
  12. package/dist/core/analytics/custom-consent.mjs.map +1 -0
  13. package/dist/core/analytics/events.d.mts +7 -0
  14. package/dist/core/analytics/events.mjs +7 -0
  15. package/dist/core/analytics/events.mjs.map +1 -1
  16. package/dist/core/analytics/index.d.mts +1 -1
  17. package/dist/core/analytics/types.d.mts +152 -16
  18. package/dist/core/cart/actions.d.mts +47 -3
  19. package/dist/core/cart/actions.mjs +29 -0
  20. package/dist/core/cart/actions.mjs.map +1 -1
  21. package/dist/core/cart/attach-quantity-input.d.mts +26 -0
  22. package/dist/core/cart/attach-quantity-input.mjs +26 -0
  23. package/dist/core/cart/attach-quantity-input.mjs.map +1 -1
  24. package/dist/core/cart/cart.d.mts +106 -6
  25. package/dist/core/cart/cart.mjs +57 -6
  26. package/dist/core/cart/cart.mjs.map +1 -1
  27. package/dist/core/cart/cookie.d.mts +15 -0
  28. package/dist/core/cart/cookie.mjs +15 -0
  29. package/dist/core/cart/cookie.mjs.map +1 -1
  30. package/dist/core/cart/form.d.mts +61 -0
  31. package/dist/core/cart/form.mjs +18 -0
  32. package/dist/core/cart/form.mjs.map +1 -1
  33. package/dist/core/cart/get-cart.d.mts +43 -2
  34. package/dist/core/cart/get-cart.mjs +33 -0
  35. package/dist/core/cart/get-cart.mjs.map +1 -1
  36. package/dist/core/cart/index.d.mts +1 -1
  37. package/dist/core/cart/quantity.d.mts +17 -0
  38. package/dist/core/cart/quantity.mjs +17 -0
  39. package/dist/core/cart/quantity.mjs.map +1 -1
  40. package/dist/core/cart/queries.d.mts +402 -369
  41. package/dist/core/cart/queries.mjs +2 -1
  42. package/dist/core/cart/queries.mjs.map +1 -1
  43. package/dist/core/cart/server-handlers.d.mts +69 -0
  44. package/dist/core/cart/server-handlers.mjs +2 -10
  45. package/dist/core/cart/server-handlers.mjs.map +1 -1
  46. package/dist/core/cart/state.d.mts +133 -0
  47. package/dist/core/cart/state.mjs +8 -0
  48. package/dist/core/cart/state.mjs.map +1 -1
  49. package/dist/core/collection/collection.d.mts +18 -2
  50. package/dist/core/collection/collection.mjs +11 -0
  51. package/dist/core/collection/collection.mjs.map +1 -1
  52. package/dist/core/collection/index.d.mts +1 -1
  53. package/dist/core/collection/reconciler.d.mts +13 -4
  54. package/dist/core/collection/reconciler.mjs.map +1 -1
  55. package/dist/core/collection/state.d.mts +7 -2
  56. package/dist/core/collection/state.mjs.map +1 -1
  57. package/dist/core/collection/url.d.mts +3 -0
  58. package/dist/core/collection/url.mjs.map +1 -1
  59. package/dist/core/constants.d.mts +1 -1
  60. package/dist/core/constants.mjs +2 -2
  61. package/dist/core/constants.mjs.map +1 -1
  62. package/dist/core/headers.mjs +2 -2
  63. package/dist/core/headers.mjs.map +1 -1
  64. package/dist/core/index.d.mts +5 -5
  65. package/dist/core/logging/logging.mjs.map +1 -1
  66. package/dist/core/money/format.mjs +6 -3
  67. package/dist/core/money/format.mjs.map +1 -1
  68. package/dist/core/observable.mjs +0 -3
  69. package/dist/core/observable.mjs.map +1 -1
  70. package/dist/core/predictive-search/form.d.mts +1 -0
  71. package/dist/core/predictive-search/form.mjs +1 -0
  72. package/dist/core/predictive-search/form.mjs.map +1 -1
  73. package/dist/core/predictive-search/index.d.mts +1 -1
  74. package/dist/core/predictive-search/queries.d.mts +40 -4
  75. package/dist/core/predictive-search/queries.mjs +7 -1
  76. package/dist/core/predictive-search/queries.mjs.map +1 -1
  77. package/dist/core/predictive-search/search.d.mts +43 -10
  78. package/dist/core/predictive-search/search.mjs +34 -0
  79. package/dist/core/predictive-search/search.mjs.map +1 -1
  80. package/dist/core/predictive-search/server-handlers.d.mts +31 -5
  81. package/dist/core/predictive-search/server-handlers.mjs +5 -3
  82. package/dist/core/predictive-search/server-handlers.mjs.map +1 -1
  83. package/dist/core/predictive-search/store.d.mts +3 -1
  84. package/dist/core/predictive-search/store.mjs.map +1 -1
  85. package/dist/core/predictive-search/url.d.mts +50 -8
  86. package/dist/core/predictive-search/url.mjs +9 -0
  87. package/dist/core/predictive-search/url.mjs.map +1 -1
  88. package/dist/core/product/form.d.mts +21 -2
  89. package/dist/core/product/form.mjs +13 -0
  90. package/dist/core/product/form.mjs.map +1 -1
  91. package/dist/core/product/index.d.mts +1 -1
  92. package/dist/core/product/options.d.mts +2 -2
  93. package/dist/core/product/options.mjs +2 -2
  94. package/dist/core/product/options.mjs.map +1 -1
  95. package/dist/core/product/product-form.d.mts +33 -1
  96. package/dist/core/product/product-form.mjs +30 -0
  97. package/dist/core/product/product-form.mjs.map +1 -1
  98. package/dist/core/product/state.d.mts +41 -0
  99. package/dist/core/product/url.d.mts +6 -0
  100. package/dist/core/product/url.mjs.map +1 -1
  101. package/dist/core/request-context.d.mts +8 -5
  102. package/dist/core/request-context.mjs +17 -39
  103. package/dist/core/request-context.mjs.map +1 -1
  104. package/dist/core/request-routing/handle-shopify-routes.mjs +4 -1
  105. package/dist/core/request-routing/handle-shopify-routes.mjs.map +1 -1
  106. package/dist/core/request-routing/interceptors/ajax-api.mjs +1 -1
  107. package/dist/core/request-routing/interceptors/api-proxy.mjs +1 -1
  108. package/dist/core/request-routing/interceptors/checkout.mjs +22 -6
  109. package/dist/core/request-routing/interceptors/checkout.mjs.map +1 -1
  110. package/dist/core/request-routing/interceptors/mcp-proxy.mjs +1 -1
  111. package/dist/core/request-routing/interceptors/proxy.mjs +36 -10
  112. package/dist/core/request-routing/interceptors/proxy.mjs.map +1 -1
  113. package/dist/core/request-routing/interceptors/query-param-redirect.mjs +5 -11
  114. package/dist/core/request-routing/interceptors/query-param-redirect.mjs.map +1 -1
  115. package/dist/core/request-routing/interceptors/sfapi-proxy.mjs +19 -1
  116. package/dist/core/request-routing/interceptors/sfapi-proxy.mjs.map +1 -1
  117. package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
  118. package/dist/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
  119. package/dist/core/request-routing/interceptors/well-known.mjs +1 -1
  120. package/dist/core/shopify-scripts/analytics.mjs +2 -2
  121. package/dist/core/shopify-scripts/analytics.mjs.map +1 -1
  122. package/dist/core/shopify-scripts/constants.mjs +4 -2
  123. package/dist/core/shopify-scripts/constants.mjs.map +1 -1
  124. package/dist/core/shopify-scripts/global.mjs +9 -4
  125. package/dist/core/shopify-scripts/global.mjs.map +1 -1
  126. package/dist/core/shopify-scripts/index.d.mts +1 -0
  127. package/dist/core/shopify-scripts/index.mjs +14 -12
  128. package/dist/core/shopify-scripts/index.mjs.map +1 -1
  129. package/dist/core/shopify-scripts/initialize.d.mts +1 -0
  130. package/dist/core/shopify-scripts/initialize.mjs +10 -3
  131. package/dist/core/shopify-scripts/initialize.mjs.map +1 -1
  132. package/dist/core/shopify-scripts/types.d.mts +6 -0
  133. package/dist/core/url.mjs +21 -2
  134. package/dist/core/url.mjs.map +1 -1
  135. package/dist/customer-account/client.d.mts +128 -4
  136. package/dist/customer-account/client.mjs +27 -1
  137. package/dist/customer-account/client.mjs.map +1 -1
  138. package/dist/customer-account/errors.d.mts +46 -0
  139. package/dist/customer-account/errors.mjs +46 -0
  140. package/dist/customer-account/errors.mjs.map +1 -1
  141. package/dist/customer-account/graphql.d.mts +68 -0
  142. package/dist/customer-account/graphql.mjs +47 -0
  143. package/dist/customer-account/graphql.mjs.map +1 -1
  144. package/dist/customer-account/session.d.mts +114 -23
  145. package/dist/customer-account/session.mjs +27 -12
  146. package/dist/customer-account/session.mjs.map +1 -1
  147. package/dist/customer-account/type-resolver.d.mts +23 -0
  148. package/dist/customer-account-api-types.d.ts +570 -374
  149. package/dist/customer-account.schema.json +2981 -1527
  150. package/dist/development/client/client.mjs +2 -2
  151. package/dist/development/client/errors.d.mts +19 -19
  152. package/dist/development/client/errors.mjs +20 -20
  153. package/dist/development/client/errors.mjs.map +1 -1
  154. package/dist/development/client/types.d.mts +91 -10
  155. package/dist/development/core/analytics/cart-tracker.d.mts +14 -0
  156. package/dist/development/core/analytics/cart-tracker.mjs +15 -1
  157. package/dist/development/core/analytics/cart-tracker.mjs.map +1 -1
  158. package/dist/development/core/analytics/custom-consent.mjs +12 -0
  159. package/dist/development/core/analytics/custom-consent.mjs.map +1 -0
  160. package/dist/development/core/analytics/events.d.mts +7 -0
  161. package/dist/development/core/analytics/events.mjs +7 -0
  162. package/dist/development/core/analytics/events.mjs.map +1 -1
  163. package/dist/development/core/analytics/index.d.mts +1 -1
  164. package/dist/development/core/analytics/types.d.mts +152 -16
  165. package/dist/development/core/cart/actions.d.mts +47 -3
  166. package/dist/development/core/cart/actions.mjs +29 -0
  167. package/dist/development/core/cart/actions.mjs.map +1 -1
  168. package/dist/development/core/cart/attach-quantity-input.d.mts +26 -0
  169. package/dist/development/core/cart/attach-quantity-input.mjs +26 -0
  170. package/dist/development/core/cart/attach-quantity-input.mjs.map +1 -1
  171. package/dist/development/core/cart/cart.d.mts +106 -6
  172. package/dist/development/core/cart/cart.mjs +57 -6
  173. package/dist/development/core/cart/cart.mjs.map +1 -1
  174. package/dist/development/core/cart/cookie.d.mts +15 -0
  175. package/dist/development/core/cart/cookie.mjs +15 -0
  176. package/dist/development/core/cart/cookie.mjs.map +1 -1
  177. package/dist/development/core/cart/form.d.mts +61 -0
  178. package/dist/development/core/cart/form.mjs +18 -0
  179. package/dist/development/core/cart/form.mjs.map +1 -1
  180. package/dist/development/core/cart/get-cart.d.mts +43 -2
  181. package/dist/development/core/cart/get-cart.mjs +33 -0
  182. package/dist/development/core/cart/get-cart.mjs.map +1 -1
  183. package/dist/development/core/cart/index.d.mts +1 -1
  184. package/dist/development/core/cart/quantity.d.mts +17 -0
  185. package/dist/development/core/cart/quantity.mjs +17 -0
  186. package/dist/development/core/cart/quantity.mjs.map +1 -1
  187. package/dist/development/core/cart/queries.d.mts +345 -312
  188. package/dist/development/core/cart/queries.mjs +2 -1
  189. package/dist/development/core/cart/queries.mjs.map +1 -1
  190. package/dist/development/core/cart/server-handlers.d.mts +69 -0
  191. package/dist/development/core/cart/server-handlers.mjs +2 -10
  192. package/dist/development/core/cart/server-handlers.mjs.map +1 -1
  193. package/dist/development/core/cart/state.d.mts +133 -0
  194. package/dist/development/core/cart/state.mjs +8 -0
  195. package/dist/development/core/cart/state.mjs.map +1 -1
  196. package/dist/development/core/collection/collection.d.mts +18 -2
  197. package/dist/development/core/collection/collection.mjs +11 -0
  198. package/dist/development/core/collection/collection.mjs.map +1 -1
  199. package/dist/development/core/collection/index.d.mts +1 -1
  200. package/dist/development/core/collection/reconciler.d.mts +13 -4
  201. package/dist/development/core/collection/reconciler.mjs.map +1 -1
  202. package/dist/development/core/collection/state.d.mts +7 -2
  203. package/dist/development/core/collection/state.mjs.map +1 -1
  204. package/dist/development/core/collection/url.d.mts +3 -0
  205. package/dist/development/core/collection/url.mjs.map +1 -1
  206. package/dist/development/core/constants.mjs +1 -1
  207. package/dist/development/core/constants.mjs.map +1 -1
  208. package/dist/development/core/development.d.mts +5 -5
  209. package/dist/development/core/headers.mjs +2 -2
  210. package/dist/development/core/headers.mjs.map +1 -1
  211. package/dist/development/core/index.d.mts +4 -4
  212. package/dist/development/core/logging/logging.mjs.map +1 -1
  213. package/dist/development/core/money/format.mjs +6 -3
  214. package/dist/development/core/money/format.mjs.map +1 -1
  215. package/dist/development/core/observable.mjs +0 -3
  216. package/dist/development/core/observable.mjs.map +1 -1
  217. package/dist/development/core/predictive-search/form.d.mts +1 -0
  218. package/dist/development/core/predictive-search/form.mjs +1 -0
  219. package/dist/development/core/predictive-search/form.mjs.map +1 -1
  220. package/dist/development/core/predictive-search/index.d.mts +1 -1
  221. package/dist/development/core/predictive-search/queries.d.mts +38 -2
  222. package/dist/development/core/predictive-search/queries.mjs +7 -1
  223. package/dist/development/core/predictive-search/queries.mjs.map +1 -1
  224. package/dist/development/core/predictive-search/search.d.mts +43 -10
  225. package/dist/development/core/predictive-search/search.mjs +34 -0
  226. package/dist/development/core/predictive-search/search.mjs.map +1 -1
  227. package/dist/development/core/predictive-search/server-handlers.d.mts +31 -5
  228. package/dist/development/core/predictive-search/server-handlers.mjs +5 -3
  229. package/dist/development/core/predictive-search/server-handlers.mjs.map +1 -1
  230. package/dist/development/core/predictive-search/store.d.mts +3 -1
  231. package/dist/development/core/predictive-search/store.mjs.map +1 -1
  232. package/dist/development/core/predictive-search/url.d.mts +50 -8
  233. package/dist/development/core/predictive-search/url.mjs +9 -0
  234. package/dist/development/core/predictive-search/url.mjs.map +1 -1
  235. package/dist/development/core/product/form.d.mts +21 -2
  236. package/dist/development/core/product/form.mjs +13 -0
  237. package/dist/development/core/product/form.mjs.map +1 -1
  238. package/dist/development/core/product/index.d.mts +1 -1
  239. package/dist/development/core/product/options.d.mts +2 -2
  240. package/dist/development/core/product/options.mjs +2 -2
  241. package/dist/development/core/product/options.mjs.map +1 -1
  242. package/dist/development/core/product/product-form.d.mts +33 -1
  243. package/dist/development/core/product/product-form.mjs +30 -0
  244. package/dist/development/core/product/product-form.mjs.map +1 -1
  245. package/dist/development/core/product/state.d.mts +41 -0
  246. package/dist/development/core/product/url.d.mts +6 -0
  247. package/dist/development/core/product/url.mjs.map +1 -1
  248. package/dist/development/core/request-context.d.mts +8 -5
  249. package/dist/development/core/request-context.mjs +17 -39
  250. package/dist/development/core/request-context.mjs.map +1 -1
  251. package/dist/development/core/request-routing/handle-shopify-routes.mjs +4 -1
  252. package/dist/development/core/request-routing/handle-shopify-routes.mjs.map +1 -1
  253. package/dist/development/core/request-routing/interceptors/ajax-api.mjs +1 -1
  254. package/dist/development/core/request-routing/interceptors/api-proxy.mjs +1 -1
  255. package/dist/development/core/request-routing/interceptors/checkout.mjs +22 -6
  256. package/dist/development/core/request-routing/interceptors/checkout.mjs.map +1 -1
  257. package/dist/development/core/request-routing/interceptors/mcp-proxy.mjs +1 -1
  258. package/dist/development/core/request-routing/interceptors/proxy.mjs +36 -10
  259. package/dist/development/core/request-routing/interceptors/proxy.mjs.map +1 -1
  260. package/dist/development/core/request-routing/interceptors/query-param-redirect.mjs +5 -11
  261. package/dist/development/core/request-routing/interceptors/query-param-redirect.mjs.map +1 -1
  262. package/dist/development/core/request-routing/interceptors/sfapi-proxy.mjs +19 -1
  263. package/dist/development/core/request-routing/interceptors/sfapi-proxy.mjs.map +1 -1
  264. package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs +35 -0
  265. package/dist/development/core/request-routing/interceptors/ucp-mcp-proxy.mjs.map +1 -0
  266. package/dist/development/core/request-routing/interceptors/well-known.mjs +1 -1
  267. package/dist/development/core/shopify-scripts/analytics.mjs +2 -2
  268. package/dist/development/core/shopify-scripts/analytics.mjs.map +1 -1
  269. package/dist/development/core/shopify-scripts/constants.mjs +4 -2
  270. package/dist/development/core/shopify-scripts/constants.mjs.map +1 -1
  271. package/dist/development/core/shopify-scripts/global.mjs +9 -4
  272. package/dist/development/core/shopify-scripts/global.mjs.map +1 -1
  273. package/dist/development/core/shopify-scripts/index.d.mts +1 -0
  274. package/dist/development/core/shopify-scripts/index.mjs +14 -12
  275. package/dist/development/core/shopify-scripts/index.mjs.map +1 -1
  276. package/dist/development/core/shopify-scripts/initialize.d.mts +1 -0
  277. package/dist/development/core/shopify-scripts/initialize.mjs +10 -3
  278. package/dist/development/core/shopify-scripts/initialize.mjs.map +1 -1
  279. package/dist/development/core/shopify-scripts/types.d.mts +6 -0
  280. package/dist/development/core/url.mjs +21 -2
  281. package/dist/development/core/url.mjs.map +1 -1
  282. package/dist/development/customer-account/session.d.mts +69 -13
  283. package/dist/development/customer-account/session.mjs +4 -0
  284. package/dist/development/customer-account/session.mjs.map +1 -1
  285. package/dist/development/globals.d.mts +6 -5
  286. package/dist/development/graphql/generated/graphql-env.d.mts +459 -16
  287. package/dist/development/graphql/generated/storefront-api-types.d.mts +1 -1
  288. package/dist/development/graphql/graphql.d.mts +33 -0
  289. package/dist/development/graphql/graphql.mjs +30 -0
  290. package/dist/development/graphql/graphql.mjs.map +1 -1
  291. package/dist/development/graphql/type-resolver.d.mts +14 -0
  292. package/dist/development/react/cart.d.mts +85 -14
  293. package/dist/development/react/cart.mjs +123 -3
  294. package/dist/development/react/cart.mjs.map +1 -1
  295. package/dist/development/react/collection.d.mts +2 -4
  296. package/dist/development/react/collection.mjs.map +1 -1
  297. package/dist/development/react/index.d.mts +8 -6
  298. package/dist/development/react/index.mjs +2 -2
  299. package/dist/development/react/predictive-search.d.mts +68 -7
  300. package/dist/development/react/predictive-search.mjs +54 -12
  301. package/dist/development/react/predictive-search.mjs.map +1 -1
  302. package/dist/development/react/product.d.mts +1 -0
  303. package/dist/development/react/product.mjs +5 -2
  304. package/dist/development/react/product.mjs.map +1 -1
  305. package/dist/development/react/shopify-scripts.mjs +1 -0
  306. package/dist/development/react/shopify-scripts.mjs.map +1 -1
  307. package/dist/development/vue/cart.d.mts +85 -6
  308. package/dist/development/vue/cart.mjs +95 -0
  309. package/dist/development/vue/cart.mjs.map +1 -1
  310. package/dist/development/vue/collection.d.mts +83 -5
  311. package/dist/development/vue/collection.mjs +52 -0
  312. package/dist/development/vue/collection.mjs.map +1 -1
  313. package/dist/development/vue/index.d.mts +6 -4
  314. package/dist/development/vue/predictive-search.d.mts +53 -6
  315. package/dist/development/vue/predictive-search.mjs +33 -0
  316. package/dist/development/vue/predictive-search.mjs.map +1 -1
  317. package/dist/development/vue/product.d.mts +6 -2
  318. package/dist/development/vue/product.mjs.map +1 -1
  319. package/dist/development/vue/shopify-scripts.d.mts +30 -3
  320. package/dist/development/vue/shopify-scripts.mjs +6 -0
  321. package/dist/development/vue/shopify-scripts.mjs.map +1 -1
  322. package/dist/globals.d.mts +6 -5
  323. package/dist/graphql/generated/customer-account-graphql-env.d.mts +1717 -1090
  324. package/dist/graphql/generated/graphql-env.d.mts +459 -16
  325. package/dist/graphql/generated/storefront-api-types.d.mts +1 -1
  326. package/dist/graphql/graphql.d.mts +33 -0
  327. package/dist/graphql/graphql.mjs +30 -0
  328. package/dist/graphql/graphql.mjs.map +1 -1
  329. package/dist/graphql/type-resolver.d.mts +14 -0
  330. package/dist/react/cart.d.mts +85 -14
  331. package/dist/react/cart.mjs +123 -3
  332. package/dist/react/cart.mjs.map +1 -1
  333. package/dist/react/collection.d.mts +2 -4
  334. package/dist/react/collection.mjs.map +1 -1
  335. package/dist/react/index.d.mts +8 -6
  336. package/dist/react/index.mjs +2 -2
  337. package/dist/react/predictive-search.d.mts +68 -7
  338. package/dist/react/predictive-search.mjs +54 -12
  339. package/dist/react/predictive-search.mjs.map +1 -1
  340. package/dist/react/product.d.mts +1 -0
  341. package/dist/react/product.mjs +5 -2
  342. package/dist/react/product.mjs.map +1 -1
  343. package/dist/react/shopify-scripts.mjs +1 -0
  344. package/dist/react/shopify-scripts.mjs.map +1 -1
  345. package/dist/storefront-api-types.d.ts +165 -20
  346. package/dist/storefront.schema.json +782 -57
  347. package/dist/vite/customer-account.mjs +191 -0
  348. package/dist/vite/customer-account.mjs.map +1 -0
  349. package/dist/vite/local-https.mjs +13 -29
  350. package/dist/vite/local-https.mjs.map +1 -1
  351. package/dist/vite/mkcert.mjs +1 -1
  352. package/dist/vue/cart.d.mts +85 -6
  353. package/dist/vue/cart.mjs +95 -0
  354. package/dist/vue/cart.mjs.map +1 -1
  355. package/dist/vue/collection.d.mts +83 -5
  356. package/dist/vue/collection.mjs +52 -0
  357. package/dist/vue/collection.mjs.map +1 -1
  358. package/dist/vue/index.d.mts +6 -4
  359. package/dist/vue/predictive-search.d.mts +53 -6
  360. package/dist/vue/predictive-search.mjs +33 -0
  361. package/dist/vue/predictive-search.mjs.map +1 -1
  362. package/dist/vue/product.d.mts +6 -2
  363. package/dist/vue/product.mjs.map +1 -1
  364. package/dist/vue/shopify-scripts.d.mts +31 -4
  365. package/dist/vue/shopify-scripts.mjs +6 -0
  366. package/dist/vue/shopify-scripts.mjs.map +1 -1
  367. package/package.json +5 -2
  368. package/skills/hydrogen-analytics/SKILL.md +6 -5
  369. package/skills/hydrogen-analytics/references/custom-consent.md +52 -0
  370. package/skills/hydrogen-analytics/references/react.md +1 -1
  371. package/skills/hydrogen-cart-metafields/SKILL.md +133 -0
  372. package/skills/hydrogen-cart-ui/SKILL.md +2 -1
  373. package/skills/hydrogen-customer-account/SKILL.md +3 -3
  374. package/skills/hydrogen-local-https/SKILL.md +22 -7
  375. package/skills/hydrogen-request-handlers/SKILL.md +5 -5
  376. package/skills/hydrogen-request-handlers/references/nuxt.md +0 -6
  377. package/skills/hydrogen-setup/references/analytics.md +24 -23
  378. package/skills/hydrogen-setup/steps/1-inspect-app.md +1 -1
  379. package/skills/hydrogen-setup/steps/11-verify.md +1 -0
  380. package/skills/hydrogen-setup/steps/2-scaffold.md +22 -1
  381. package/skills/hydrogen-smoke-test/SKILL.md +2 -2
  382. package/skills/hydrogen-storefront-client/SKILL.md +4 -2
  383. package/skills/hydrogen-variant-form/SKILL.md +10 -4
  384. package/ts-plugin/package.json +5 -0
  385. package/dist/core/shopify-scripts/consent.mjs +0 -17
  386. package/dist/core/shopify-scripts/consent.mjs.map +0 -1
  387. package/dist/core/shopify-scripts/deprecated-cookies.mjs +0 -97
  388. package/dist/core/shopify-scripts/deprecated-cookies.mjs.map +0 -1
  389. package/dist/core/shopify-scripts/utils/cookie-domain.mjs +0 -43
  390. package/dist/core/shopify-scripts/utils/cookie-domain.mjs.map +0 -1
  391. package/dist/core/shopify-scripts/utils/tracking-values.mjs +0 -65
  392. package/dist/core/shopify-scripts/utils/tracking-values.mjs.map +0 -1
  393. package/dist/core/shopify-scripts/utils/uuid.mjs +0 -17
  394. package/dist/core/shopify-scripts/utils/uuid.mjs.map +0 -1
  395. package/dist/development/core/shopify-scripts/consent.mjs +0 -17
  396. package/dist/development/core/shopify-scripts/consent.mjs.map +0 -1
  397. package/dist/development/core/shopify-scripts/deprecated-cookies.mjs +0 -97
  398. package/dist/development/core/shopify-scripts/deprecated-cookies.mjs.map +0 -1
  399. package/dist/development/core/shopify-scripts/utils/cookie-domain.mjs +0 -43
  400. package/dist/development/core/shopify-scripts/utils/cookie-domain.mjs.map +0 -1
  401. package/dist/development/core/shopify-scripts/utils/tracking-values.mjs +0 -65
  402. package/dist/development/core/shopify-scripts/utils/tracking-values.mjs.map +0 -1
  403. package/dist/development/core/shopify-scripts/utils/uuid.mjs +0 -17
  404. package/dist/development/core/shopify-scripts/utils/uuid.mjs.map +0 -1
@@ -1,24 +1,39 @@
1
1
  import { CartErrorCode, CartWarningCode } from "../../graphql/generated/storefront-api-types.mjs";
2
2
 
3
3
  //#region src/core/cart/state.d.ts
4
+ /** Storefront API monetary value — an amount string and ISO 4217 currency code. */
4
5
  interface Money {
6
+ /** Decimal amount as a string (e.g. `"29.99"`). */
5
7
  amount: string;
8
+ /** ISO 4217 currency code (e.g. `"USD"`, `"EUR"`). */
6
9
  currencyCode: string;
7
10
  }
11
+ /** Cart-level cost breakdown returned by the Storefront API. */
8
12
  interface CartCost {
13
+ /** The amount, before taxes and cart-level discounts, for the customer to pay. */
9
14
  subtotalAmount: Money;
15
+ /** Final amount the buyer pays at checkout. */
10
16
  totalAmount: Money;
17
+ /** Estimated amount due at checkout, excluding deferred payments. Equals the subtotal when no deferred payments exist. */
11
18
  checkoutChargeAmount: Money;
12
19
  }
13
20
  interface CartLineCost {
21
+ /** Line total after all applicable discounts. */
14
22
  totalAmount: Money;
23
+ /** Line subtotal before line-level discounts. */
15
24
  subtotalAmount: Money;
25
+ /** Unit price for a single quantity of this line. */
16
26
  amountPerQuantity: Money;
27
+ /** Original compare-at unit price, or `null` when no comparison exists. */
17
28
  compareAtAmountPerQuantity: Money | null;
18
29
  }
30
+ /** Product variant information attached to a {@link CartLine}. */
19
31
  interface CartLineMerchandise {
32
+ /** Storefront API GID of the product variant. */
20
33
  id: string;
34
+ /** Variant title (e.g. `"Small / Black"`). */
21
35
  title?: string;
36
+ /** Selected variant option name/value pairs (e.g. `{ name: "Size", value: "S" }`). */
22
37
  selectedOptions?: Array<{
23
38
  name: string;
24
39
  value: string;
@@ -39,12 +54,24 @@ interface CartLineMerchandise {
39
54
  quantityAvailable?: number | null;
40
55
  [key: string]: unknown;
41
56
  }
57
+ /**
58
+ * A single line item in the cart.
59
+ *
60
+ * Each line represents a product variant at a given quantity. Lines may carry
61
+ * custom {@link Attribute | attributes}, a selling plan allocation (subscriptions),
62
+ * or nested {@link CartLine.lineComponents | line components} (bundles).
63
+ */
42
64
  interface CartLine {
65
+ /** Storefront API GID of this cart line — used as the key in {@link CartPending.lines}. */
43
66
  id: string;
67
+ /** Number of units of this merchandise in the cart. */
44
68
  quantity: number;
45
69
  attributes?: Attribute[];
70
+ /** Per-line cost breakdown from the Storefront API. */
46
71
  cost: CartLineCost;
72
+ /** The product variant this line represents. */
47
73
  merchandise?: CartLineMerchandise;
74
+ /** Subscription selling plan allocation, or `null` for one-time purchases. */
48
75
  sellingPlanAllocation?: {
49
76
  sellingPlan: {
50
77
  id: string;
@@ -55,49 +82,101 @@ interface CartLine {
55
82
  id: string;
56
83
  };
57
84
  } | null;
85
+ /** Nested child lines when this line is a bundle parent. */
58
86
  lineComponents?: CartLine[];
59
87
  }
60
88
  interface CartLineConnection {
61
89
  nodes: CartLine[];
62
90
  [key: string]: unknown;
63
91
  }
92
+ /** A discount code applied to the cart. */
64
93
  interface DiscountCode {
65
94
  code: string;
95
+ /** Whether the code is applicable to the cart's current contents. Set optimistically to `false` for newly added codes until the server confirms. */
66
96
  applicable: boolean;
67
97
  }
98
+ /** A validation error returned by a Storefront API cart mutation. */
68
99
  interface CartUserError {
100
+ /** Machine-readable error code, or `null` for unclassified errors. */
69
101
  code: CartErrorCode | null;
70
102
  message: string;
103
+ /** JSONPath to the input field that caused the error (e.g. `["lines", "0", "quantity"]`). */
71
104
  field?: string[];
72
105
  }
106
+ /** A non-blocking warning returned by a Storefront API cart mutation. */
73
107
  interface CartWarning {
74
108
  code: CartWarningCode;
75
109
  message: string;
76
110
  }
111
+ /** Errors and warnings from a single cart mutation response. */
77
112
  interface CartErrorGroup {
78
113
  userErrors: CartUserError[];
79
114
  warnings: CartWarning[];
80
115
  }
116
+ /** A network-level error encountered during a cart operation (e.g. non-2xx response, timeout). Aborted requests are not recorded. */
81
117
  interface CartNetworkEntry {
82
118
  message: string;
119
+ /** HTTP status code when available. */
83
120
  status?: number;
84
121
  }
122
+ /** A key-value pair attached to the cart or to an individual {@link CartLine}. */
85
123
  interface Attribute {
86
124
  key: string;
87
125
  value: string | null;
88
126
  }
127
+ /**
128
+ * Tracks which parts of the cart have in-flight mutations.
129
+ *
130
+ * `lines` and `discountCodes` are `Set<string>` keyed by line ID / discount code,
131
+ * enabling per-item pending indicators rather than a single global spinner.
132
+ * `cost` is a boolean because *any* line or discount change affects the total —
133
+ * use it to show pending UI on price displays without computing optimistic amounts.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const { pending } = store.getState();
138
+ *
139
+ * // Per-line-item pending indicator
140
+ * if (pending.lines.has(lineId)) {
141
+ * showSpinner(lineId);
142
+ * }
143
+ *
144
+ * // Cart total is stale — show pending UI, don't compute a price
145
+ * if (pending.cost) {
146
+ * dimTotalDisplay();
147
+ * }
148
+ * ```
149
+ */
89
150
  interface CartPending {
151
+ /** Line IDs with in-flight add, quantity, or remove mutations. New lines use their `optimistic:` ID until the server confirms. */
90
152
  lines: Set<string>;
91
153
  note: boolean;
92
154
  attributes: boolean;
155
+ /** Discount codes with in-flight apply/remove mutations. */
93
156
  discountCodes: Set<string>;
157
+ /** `true` when any mutation may affect cart pricing — show pending UI on cost displays. */
94
158
  cost?: boolean;
95
159
  }
160
+ /**
161
+ * Per-resource error tracking for the cart.
162
+ *
163
+ * Errors are bucketed by the resource they affect — `lines`, `note`, `attributes`,
164
+ * `discountCodes`, or `cart` (for cross-cutting errors). Each bucket carries a
165
+ * `*UpdatedAt` timestamp (ms since epoch) so the UI can show stale-error indicators
166
+ * or auto-dismiss after a timeout.
167
+ *
168
+ * `network` collects transport-level failures (timeouts, 5xx) separately from
169
+ * Storefront API user errors.
170
+ */
96
171
  interface CartErrorState {
172
+ /** Cross-cutting cart errors not attributable to a specific resource. */
97
173
  cart: CartErrorGroup;
174
+ /** Errors keyed by {@link CartLine.id}. */
98
175
  lines: Map<string, CartErrorGroup>;
99
176
  note: CartErrorGroup;
177
+ /** Errors keyed by {@link Attribute.key}. */
100
178
  attributes: Map<string, CartErrorGroup>;
179
+ /** Errors keyed by discount code string. */
101
180
  discountCodes: Map<string, CartErrorGroup>;
102
181
  network: CartNetworkEntry[];
103
182
  lastUpdatedAt: number;
@@ -108,29 +187,83 @@ interface CartErrorState {
108
187
  discountCodesUpdatedAt: number;
109
188
  networkUpdatedAt: number;
110
189
  }
190
+ /**
191
+ * Cart data returned by the Storefront API.
192
+ *
193
+ * This is the server's authoritative snapshot — prices, discount applicability,
194
+ * and totals come exclusively from here, never from client-side computation.
195
+ * The index signature allows custom cart query fields to pass through.
196
+ */
111
197
  interface CartData {
198
+ /** Storefront API Cart GID, or `null` when no cart exists yet. */
112
199
  id: string | null;
200
+ /** `Cart.updatedAt` (ISO 8601). Absent until the first server response. */
201
+ updatedAt?: string;
202
+ /** Shopify checkout URL. Changes when buyer identity or consent state changes. */
113
203
  checkoutUrl?: string | null;
204
+ /** Sum of all line quantities. */
114
205
  totalQuantity: number;
206
+ /** Cart-level cost breakdown (subtotal, total, checkout charge). */
115
207
  cost: CartCost;
208
+ /** Buyer-supplied cart note, or `null`/empty when unset. */
116
209
  note?: string | null;
210
+ /** Cart-level custom attributes. */
117
211
  attributes: Attribute[];
212
+ /** The cart's line items. */
118
213
  lines: CartLineConnection;
214
+ /** Discount codes applied to the cart, with their applicability. */
119
215
  discountCodes: DiscountCode[];
120
216
  [key: string]: unknown;
121
217
  }
218
+ /**
219
+ * Full observable state of the cart store.
220
+ *
221
+ * Framework adapters subscribe to this via `CartStore.subscribe` (or the
222
+ * framework-specific hooks like `useCart`). The generic `TData` parameter
223
+ * flows from your server handler configuration, so `data` is typed to match
224
+ * your custom cart query.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * const state = store.getState();
229
+ *
230
+ * if (state.loading) {
231
+ * // Initial cart load in progress — show skeleton
232
+ * }
233
+ *
234
+ * if (state.pending.lines.size > 0) {
235
+ * // One or more lines are mid-mutation — show per-line spinners
236
+ * }
237
+ *
238
+ * if (state.errors.network.length > 0) {
239
+ * // Network errors occurred — show retry prompt
240
+ * }
241
+ * ```
242
+ */
122
243
  interface CartState<TData extends CartData = CartData> {
244
+ /** The latest server-confirmed cart data, with optimistic projections applied on top. */
123
245
  data: TData;
246
+ /** `true` during a full-cart fetch — initial load, after `reset()`, or before connect with no `initialData`. */
124
247
  loading: boolean;
125
248
  /** Non-rejecting signal that resolves when the current full-cart load settles or is invalidated. */
126
249
  readonly readyPromise?: PromiseLike<void>;
250
+ /** Per-resource in-flight mutation tracking. */
127
251
  pending: CartPending;
252
+ /** `true` when a background revalidation is in flight (triggered by overlapping mutations or `refresh()`). */
128
253
  revalidating?: boolean;
254
+ /** Per-resource error and warning state. */
129
255
  errors: CartErrorState;
130
256
  }
257
+ /** Creates an empty {@link CartErrorGroup} — no user errors, no warnings. */
131
258
  declare function createEmptyErrorGroup(): CartErrorGroup;
259
+ /** Creates an empty {@link CartErrorState} — all timestamps at `0`, all buckets empty. */
132
260
  declare function createEmptyCartErrors(): CartErrorState;
261
+ /**
262
+ * Frozen {@link CartData} representing an empty cart — `id: null`, zero quantities,
263
+ * zero-amount costs. Used as the initial/fallback value before the first server response.
264
+ */
133
265
  declare const EMPTY_CART_DATA: CartData;
266
+ /** Shared empty {@link CartState} — backed by {@link EMPTY_CART_DATA}, defaults to `loading: true`. */
134
267
  declare const EMPTY_CART_STATE: CartState;
135
268
  //#endregion
136
269
  export { Attribute, CartCost, CartData, CartErrorGroup, CartErrorState, CartLine, CartLineConnection, CartLineCost, CartLineMerchandise, CartNetworkEntry, CartPending, CartState, CartUserError, CartWarning, DiscountCode, EMPTY_CART_DATA, EMPTY_CART_STATE, createEmptyCartErrors, createEmptyErrorGroup };
@@ -8,12 +8,14 @@ function createEmptyPending() {
8
8
  cost: false
9
9
  };
10
10
  }
11
+ /** Creates an empty {@link CartErrorGroup} — no user errors, no warnings. */
11
12
  function createEmptyErrorGroup() {
12
13
  return {
13
14
  userErrors: [],
14
15
  warnings: []
15
16
  };
16
17
  }
18
+ /** Creates an empty {@link CartErrorState} — all timestamps at `0`, all buckets empty. */
17
19
  function createEmptyCartErrors() {
18
20
  return {
19
21
  cart: createEmptyErrorGroup(),
@@ -31,6 +33,10 @@ function createEmptyCartErrors() {
31
33
  networkUpdatedAt: 0
32
34
  };
33
35
  }
36
+ /**
37
+ * Frozen {@link CartData} representing an empty cart — `id: null`, zero quantities,
38
+ * zero-amount costs. Used as the initial/fallback value before the first server response.
39
+ */
34
40
  const EMPTY_CART_DATA = Object.freeze({
35
41
  id: null,
36
42
  checkoutUrl: null,
@@ -62,6 +68,7 @@ function createCartState(data, { loading = false } = {}) {
62
68
  errors: createEmptyCartErrors()
63
69
  };
64
70
  }
71
+ /** Creates an empty {@link CartState} backed by {@link EMPTY_CART_DATA}. Defaults to `loading: true`. */
65
72
  function createEmptyCartState({ loading = true } = {}) {
66
73
  return createCartState({
67
74
  ...EMPTY_CART_DATA,
@@ -69,6 +76,7 @@ function createEmptyCartState({ loading = true } = {}) {
69
76
  discountCodes: []
70
77
  }, { loading });
71
78
  }
79
+ /** Shared empty {@link CartState} — backed by {@link EMPTY_CART_DATA}, defaults to `loading: true`. */
72
80
  const EMPTY_CART_STATE = createEmptyCartState();
73
81
 
74
82
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"state.mjs","names":[],"sources":["../../../../src/core/cart/state.ts"],"sourcesContent":["import type { CartErrorCode, CartWarningCode } from \"../../graphql/generated/storefront-api-types\";\n\nexport interface Money {\n amount: string;\n currencyCode: string;\n}\n\nexport interface CartCost {\n subtotalAmount: Money;\n totalAmount: Money;\n checkoutChargeAmount: Money;\n}\n\nexport interface CartLineCost {\n totalAmount: Money;\n subtotalAmount: Money;\n amountPerQuantity: Money;\n compareAtAmountPerQuantity: Money | null;\n}\n\nexport interface CartLineMerchandise {\n id: string;\n title?: string;\n selectedOptions?: Array<{ name: string; value: string }>;\n product: {\n title: string;\n handle?: string;\n [key: string]: unknown;\n };\n image?: {\n id?: string | null;\n url: string;\n altText?: string | null;\n width?: number | null;\n height?: number | null;\n [key: string]: unknown;\n } | null;\n quantityAvailable?: number | null;\n [key: string]: unknown;\n}\n\nexport interface CartLine {\n id: string;\n quantity: number;\n attributes?: Attribute[];\n cost: CartLineCost;\n merchandise?: CartLineMerchandise;\n sellingPlanAllocation?: { sellingPlan: { id: string } } | null;\n parentRelationship?: { parent: { id: string } } | null;\n lineComponents?: CartLine[];\n}\n\nexport interface CartLineConnection {\n nodes: CartLine[];\n [key: string]: unknown;\n}\n\nexport interface DiscountCode {\n code: string;\n applicable: boolean;\n}\n\nexport interface CartUserError {\n code: CartErrorCode | null;\n message: string;\n field?: string[];\n}\n\nexport interface CartWarning {\n code: CartWarningCode;\n message: string;\n}\n\nexport interface CartErrorGroup {\n userErrors: CartUserError[];\n warnings: CartWarning[];\n}\n\nexport interface CartNetworkEntry {\n message: string;\n status?: number;\n}\n\nexport interface Attribute {\n key: string;\n value: string | null;\n}\n\nexport interface CartPending {\n lines: Set<string>;\n note: boolean;\n attributes: boolean;\n discountCodes: Set<string>;\n cost?: boolean;\n}\n\nexport interface CartErrorState {\n cart: CartErrorGroup;\n lines: Map<string, CartErrorGroup>;\n note: CartErrorGroup;\n attributes: Map<string, CartErrorGroup>;\n discountCodes: Map<string, CartErrorGroup>;\n network: CartNetworkEntry[];\n lastUpdatedAt: number;\n cartUpdatedAt: number;\n linesUpdatedAt: number;\n noteUpdatedAt: number;\n attributesUpdatedAt: number;\n discountCodesUpdatedAt: number;\n networkUpdatedAt: number;\n}\n\nexport interface CartData {\n id: string | null;\n checkoutUrl?: string | null;\n totalQuantity: number;\n cost: CartCost;\n note?: string | null;\n attributes: Attribute[];\n lines: CartLineConnection;\n discountCodes: DiscountCode[];\n [key: string]: unknown;\n}\n\nexport interface CartState<TData extends CartData = CartData> {\n data: TData;\n loading: boolean;\n /** Non-rejecting signal that resolves when the current full-cart load settles or is invalidated. */\n readonly readyPromise?: PromiseLike<void>;\n pending: CartPending;\n revalidating?: boolean;\n errors: CartErrorState;\n}\n\nexport function createEmptyPending(): CartPending {\n return {\n lines: new Set(),\n note: false,\n attributes: false,\n discountCodes: new Set(),\n cost: false,\n };\n}\n\nexport function createEmptyErrorGroup(): CartErrorGroup {\n return { userErrors: [], warnings: [] };\n}\n\nexport function createEmptyCartErrors(): CartErrorState {\n return {\n cart: createEmptyErrorGroup(),\n lines: new Map(),\n note: createEmptyErrorGroup(),\n attributes: new Map(),\n discountCodes: new Map(),\n network: [],\n lastUpdatedAt: 0,\n cartUpdatedAt: 0,\n linesUpdatedAt: 0,\n noteUpdatedAt: 0,\n attributesUpdatedAt: 0,\n discountCodesUpdatedAt: 0,\n networkUpdatedAt: 0,\n };\n}\n\nexport const EMPTY_CART_DATA: CartData = Object.freeze({\n id: null,\n checkoutUrl: null,\n totalQuantity: 0,\n cost: Object.freeze({\n subtotalAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n totalAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n checkoutChargeAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n }),\n note: \"\",\n attributes: [] as Attribute[],\n lines: Object.freeze({ nodes: [] as CartLine[] }),\n discountCodes: [] as DiscountCode[],\n});\n\nexport function createCartState<TData extends CartData>(\n data: TData,\n { loading = false }: { loading?: boolean } = {},\n): CartState<TData> {\n return {\n data,\n loading,\n pending: createEmptyPending(),\n errors: createEmptyCartErrors(),\n };\n}\n\nexport function createEmptyCartState({ loading = true }: { loading?: boolean } = {}): CartState {\n return createCartState(\n {\n ...EMPTY_CART_DATA,\n lines: { nodes: [] },\n discountCodes: [],\n },\n { loading },\n );\n}\n\nexport const EMPTY_CART_STATE: CartState = createEmptyCartState();\n"],"mappings":";AAsIA,SAAgB,qBAAkC;AAChD,QAAO;EACL,uBAAO,IAAI,KAAK;EAChB,MAAM;EACN,YAAY;EACZ,+BAAe,IAAI,KAAK;EACxB,MAAM;EACP;;AAGH,SAAgB,wBAAwC;AACtD,QAAO;EAAE,YAAY,EAAE;EAAE,UAAU,EAAE;EAAE;;AAGzC,SAAgB,wBAAwC;AACtD,QAAO;EACL,MAAM,uBAAuB;EAC7B,uBAAO,IAAI,KAAK;EAChB,MAAM,uBAAuB;EAC7B,4BAAY,IAAI,KAAK;EACrB,+BAAe,IAAI,KAAK;EACxB,SAAS,EAAE;EACX,eAAe;EACf,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,qBAAqB;EACrB,wBAAwB;EACxB,kBAAkB;EACnB;;AAGH,MAAa,kBAA4B,OAAO,OAAO;CACrD,IAAI;CACJ,aAAa;CACb,eAAe;CACf,MAAM,OAAO,OAAO;EAClB,gBAAgB,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EAChE,aAAa,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EAC7D,sBAAsB,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EACvE,CAAC;CACF,MAAM;CACN,YAAY,EAAE;CACd,OAAO,OAAO,OAAO,EAAE,OAAO,EAAE,EAAgB,CAAC;CACjD,eAAe,EAAE;CAClB,CAAC;AAEF,SAAgB,gBACd,MACA,EAAE,UAAU,UAAiC,EAAE,EAC7B;AAClB,QAAO;EACL;EACA;EACA,SAAS,oBAAoB;EAC7B,QAAQ,uBAAuB;EAChC;;AAGH,SAAgB,qBAAqB,EAAE,UAAU,SAAgC,EAAE,EAAa;AAC9F,QAAO,gBACL;EACE,GAAG;EACH,OAAO,EAAE,OAAO,EAAE,EAAE;EACpB,eAAe,EAAE;EAClB,EACD,EAAE,SAAS,CACZ;;AAGH,MAAa,mBAA8B,sBAAsB"}
1
+ {"version":3,"file":"state.mjs","names":[],"sources":["../../../../src/core/cart/state.ts"],"sourcesContent":["import type { CartErrorCode, CartWarningCode } from \"../../graphql/generated/storefront-api-types\";\n\n/** Storefront API monetary value — an amount string and ISO 4217 currency code. */\nexport interface Money {\n /** Decimal amount as a string (e.g. `\"29.99\"`). */\n amount: string;\n /** ISO 4217 currency code (e.g. `\"USD\"`, `\"EUR\"`). */\n currencyCode: string;\n}\n\n/** Cart-level cost breakdown returned by the Storefront API. */\nexport interface CartCost {\n /** The amount, before taxes and cart-level discounts, for the customer to pay. */\n subtotalAmount: Money;\n /** Final amount the buyer pays at checkout. */\n totalAmount: Money;\n /** Estimated amount due at checkout, excluding deferred payments. Equals the subtotal when no deferred payments exist. */\n checkoutChargeAmount: Money;\n}\n\nexport interface CartLineCost {\n /** Line total after all applicable discounts. */\n totalAmount: Money;\n /** Line subtotal before line-level discounts. */\n subtotalAmount: Money;\n /** Unit price for a single quantity of this line. */\n amountPerQuantity: Money;\n /** Original compare-at unit price, or `null` when no comparison exists. */\n compareAtAmountPerQuantity: Money | null;\n}\n\n/** Product variant information attached to a {@link CartLine}. */\nexport interface CartLineMerchandise {\n /** Storefront API GID of the product variant. */\n id: string;\n /** Variant title (e.g. `\"Small / Black\"`). */\n title?: string;\n /** Selected variant option name/value pairs (e.g. `{ name: \"Size\", value: \"S\" }`). */\n selectedOptions?: Array<{ name: string; value: string }>;\n product: {\n title: string;\n handle?: string;\n [key: string]: unknown;\n };\n image?: {\n id?: string | null;\n url: string;\n altText?: string | null;\n width?: number | null;\n height?: number | null;\n [key: string]: unknown;\n } | null;\n quantityAvailable?: number | null;\n [key: string]: unknown;\n}\n\n/**\n * A single line item in the cart.\n *\n * Each line represents a product variant at a given quantity. Lines may carry\n * custom {@link Attribute | attributes}, a selling plan allocation (subscriptions),\n * or nested {@link CartLine.lineComponents | line components} (bundles).\n */\nexport interface CartLine {\n /** Storefront API GID of this cart line — used as the key in {@link CartPending.lines}. */\n id: string;\n /** Number of units of this merchandise in the cart. */\n quantity: number;\n attributes?: Attribute[];\n /** Per-line cost breakdown from the Storefront API. */\n cost: CartLineCost;\n /** The product variant this line represents. */\n merchandise?: CartLineMerchandise;\n /** Subscription selling plan allocation, or `null` for one-time purchases. */\n sellingPlanAllocation?: { sellingPlan: { id: string } } | null;\n parentRelationship?: { parent: { id: string } } | null;\n /** Nested child lines when this line is a bundle parent. */\n lineComponents?: CartLine[];\n}\n\nexport interface CartLineConnection {\n nodes: CartLine[];\n [key: string]: unknown;\n}\n\n/** A discount code applied to the cart. */\nexport interface DiscountCode {\n code: string;\n /** Whether the code is applicable to the cart's current contents. Set optimistically to `false` for newly added codes until the server confirms. */\n applicable: boolean;\n}\n\n/** A validation error returned by a Storefront API cart mutation. */\nexport interface CartUserError {\n /** Machine-readable error code, or `null` for unclassified errors. */\n code: CartErrorCode | null;\n message: string;\n /** JSONPath to the input field that caused the error (e.g. `[\"lines\", \"0\", \"quantity\"]`). */\n field?: string[];\n}\n\n/** A non-blocking warning returned by a Storefront API cart mutation. */\nexport interface CartWarning {\n code: CartWarningCode;\n message: string;\n}\n\n/** Errors and warnings from a single cart mutation response. */\nexport interface CartErrorGroup {\n userErrors: CartUserError[];\n warnings: CartWarning[];\n}\n\n/** A network-level error encountered during a cart operation (e.g. non-2xx response, timeout). Aborted requests are not recorded. */\nexport interface CartNetworkEntry {\n message: string;\n /** HTTP status code when available. */\n status?: number;\n}\n\n/** A key-value pair attached to the cart or to an individual {@link CartLine}. */\nexport interface Attribute {\n key: string;\n value: string | null;\n}\n\n/**\n * Tracks which parts of the cart have in-flight mutations.\n *\n * `lines` and `discountCodes` are `Set<string>` keyed by line ID / discount code,\n * enabling per-item pending indicators rather than a single global spinner.\n * `cost` is a boolean because *any* line or discount change affects the total —\n * use it to show pending UI on price displays without computing optimistic amounts.\n *\n * @example\n * ```ts\n * const { pending } = store.getState();\n *\n * // Per-line-item pending indicator\n * if (pending.lines.has(lineId)) {\n * showSpinner(lineId);\n * }\n *\n * // Cart total is stale — show pending UI, don't compute a price\n * if (pending.cost) {\n * dimTotalDisplay();\n * }\n * ```\n */\nexport interface CartPending {\n /** Line IDs with in-flight add, quantity, or remove mutations. New lines use their `optimistic:` ID until the server confirms. */\n lines: Set<string>;\n note: boolean;\n attributes: boolean;\n /** Discount codes with in-flight apply/remove mutations. */\n discountCodes: Set<string>;\n /** `true` when any mutation may affect cart pricing — show pending UI on cost displays. */\n cost?: boolean;\n}\n\n/**\n * Per-resource error tracking for the cart.\n *\n * Errors are bucketed by the resource they affect — `lines`, `note`, `attributes`,\n * `discountCodes`, or `cart` (for cross-cutting errors). Each bucket carries a\n * `*UpdatedAt` timestamp (ms since epoch) so the UI can show stale-error indicators\n * or auto-dismiss after a timeout.\n *\n * `network` collects transport-level failures (timeouts, 5xx) separately from\n * Storefront API user errors.\n */\nexport interface CartErrorState {\n /** Cross-cutting cart errors not attributable to a specific resource. */\n cart: CartErrorGroup;\n /** Errors keyed by {@link CartLine.id}. */\n lines: Map<string, CartErrorGroup>;\n note: CartErrorGroup;\n /** Errors keyed by {@link Attribute.key}. */\n attributes: Map<string, CartErrorGroup>;\n /** Errors keyed by discount code string. */\n discountCodes: Map<string, CartErrorGroup>;\n network: CartNetworkEntry[];\n lastUpdatedAt: number;\n cartUpdatedAt: number;\n linesUpdatedAt: number;\n noteUpdatedAt: number;\n attributesUpdatedAt: number;\n discountCodesUpdatedAt: number;\n networkUpdatedAt: number;\n}\n\n/**\n * Cart data returned by the Storefront API.\n *\n * This is the server's authoritative snapshot — prices, discount applicability,\n * and totals come exclusively from here, never from client-side computation.\n * The index signature allows custom cart query fields to pass through.\n */\nexport interface CartData {\n /** Storefront API Cart GID, or `null` when no cart exists yet. */\n id: string | null;\n /** `Cart.updatedAt` (ISO 8601). Absent until the first server response. */\n updatedAt?: string;\n /** Shopify checkout URL. Changes when buyer identity or consent state changes. */\n checkoutUrl?: string | null;\n /** Sum of all line quantities. */\n totalQuantity: number;\n /** Cart-level cost breakdown (subtotal, total, checkout charge). */\n cost: CartCost;\n /** Buyer-supplied cart note, or `null`/empty when unset. */\n note?: string | null;\n /** Cart-level custom attributes. */\n attributes: Attribute[];\n /** The cart's line items. */\n lines: CartLineConnection;\n /** Discount codes applied to the cart, with their applicability. */\n discountCodes: DiscountCode[];\n [key: string]: unknown;\n}\n\n/**\n * Full observable state of the cart store.\n *\n * Framework adapters subscribe to this via `CartStore.subscribe` (or the\n * framework-specific hooks like `useCart`). The generic `TData` parameter\n * flows from your server handler configuration, so `data` is typed to match\n * your custom cart query.\n *\n * @example\n * ```ts\n * const state = store.getState();\n *\n * if (state.loading) {\n * // Initial cart load in progress — show skeleton\n * }\n *\n * if (state.pending.lines.size > 0) {\n * // One or more lines are mid-mutation — show per-line spinners\n * }\n *\n * if (state.errors.network.length > 0) {\n * // Network errors occurred — show retry prompt\n * }\n * ```\n */\nexport interface CartState<TData extends CartData = CartData> {\n /** The latest server-confirmed cart data, with optimistic projections applied on top. */\n data: TData;\n /** `true` during a full-cart fetch — initial load, after `reset()`, or before connect with no `initialData`. */\n loading: boolean;\n /** Non-rejecting signal that resolves when the current full-cart load settles or is invalidated. */\n readonly readyPromise?: PromiseLike<void>;\n /** Per-resource in-flight mutation tracking. */\n pending: CartPending;\n /** `true` when a background revalidation is in flight (triggered by overlapping mutations or `refresh()`). */\n revalidating?: boolean;\n /** Per-resource error and warning state. */\n errors: CartErrorState;\n}\n\nexport function createEmptyPending(): CartPending {\n return {\n lines: new Set(),\n note: false,\n attributes: false,\n discountCodes: new Set(),\n cost: false,\n };\n}\n\n/** Creates an empty {@link CartErrorGroup} — no user errors, no warnings. */\nexport function createEmptyErrorGroup(): CartErrorGroup {\n return { userErrors: [], warnings: [] };\n}\n\n/** Creates an empty {@link CartErrorState} — all timestamps at `0`, all buckets empty. */\nexport function createEmptyCartErrors(): CartErrorState {\n return {\n cart: createEmptyErrorGroup(),\n lines: new Map(),\n note: createEmptyErrorGroup(),\n attributes: new Map(),\n discountCodes: new Map(),\n network: [],\n lastUpdatedAt: 0,\n cartUpdatedAt: 0,\n linesUpdatedAt: 0,\n noteUpdatedAt: 0,\n attributesUpdatedAt: 0,\n discountCodesUpdatedAt: 0,\n networkUpdatedAt: 0,\n };\n}\n\n/**\n * Frozen {@link CartData} representing an empty cart — `id: null`, zero quantities,\n * zero-amount costs. Used as the initial/fallback value before the first server response.\n */\nexport const EMPTY_CART_DATA: CartData = Object.freeze({\n id: null,\n checkoutUrl: null,\n totalQuantity: 0,\n cost: Object.freeze({\n subtotalAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n totalAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n checkoutChargeAmount: Object.freeze({ amount: \"0\", currencyCode: \"\" }),\n }),\n note: \"\",\n attributes: [] as Attribute[],\n lines: Object.freeze({ nodes: [] as CartLine[] }),\n discountCodes: [] as DiscountCode[],\n});\n\nexport function createCartState<TData extends CartData>(\n data: TData,\n { loading = false }: { loading?: boolean } = {},\n): CartState<TData> {\n return {\n data,\n loading,\n pending: createEmptyPending(),\n errors: createEmptyCartErrors(),\n };\n}\n\n/** Creates an empty {@link CartState} backed by {@link EMPTY_CART_DATA}. Defaults to `loading: true`. */\nexport function createEmptyCartState({ loading = true }: { loading?: boolean } = {}): CartState {\n return createCartState(\n {\n ...EMPTY_CART_DATA,\n lines: { nodes: [] },\n discountCodes: [],\n },\n { loading },\n );\n}\n\n/** Shared empty {@link CartState} — backed by {@link EMPTY_CART_DATA}, defaults to `loading: true`. */\nexport const EMPTY_CART_STATE: CartState = createEmptyCartState();\n"],"mappings":";AAoQA,SAAgB,qBAAkC;AAChD,QAAO;EACL,uBAAO,IAAI,KAAK;EAChB,MAAM;EACN,YAAY;EACZ,+BAAe,IAAI,KAAK;EACxB,MAAM;EACP;;;AAIH,SAAgB,wBAAwC;AACtD,QAAO;EAAE,YAAY,EAAE;EAAE,UAAU,EAAE;EAAE;;;AAIzC,SAAgB,wBAAwC;AACtD,QAAO;EACL,MAAM,uBAAuB;EAC7B,uBAAO,IAAI,KAAK;EAChB,MAAM,uBAAuB;EAC7B,4BAAY,IAAI,KAAK;EACrB,+BAAe,IAAI,KAAK;EACxB,SAAS,EAAE;EACX,eAAe;EACf,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,qBAAqB;EACrB,wBAAwB;EACxB,kBAAkB;EACnB;;;;;;AAOH,MAAa,kBAA4B,OAAO,OAAO;CACrD,IAAI;CACJ,aAAa;CACb,eAAe;CACf,MAAM,OAAO,OAAO;EAClB,gBAAgB,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EAChE,aAAa,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EAC7D,sBAAsB,OAAO,OAAO;GAAE,QAAQ;GAAK,cAAc;GAAI,CAAC;EACvE,CAAC;CACF,MAAM;CACN,YAAY,EAAE;CACd,OAAO,OAAO,OAAO,EAAE,OAAO,EAAE,EAAgB,CAAC;CACjD,eAAe,EAAE;CAClB,CAAC;AAEF,SAAgB,gBACd,MACA,EAAE,UAAU,UAAiC,EAAE,EAC7B;AAClB,QAAO;EACL;EACA;EACA,SAAS,oBAAoB;EAC7B,QAAQ,uBAAuB;EAChC;;;AAIH,SAAgB,qBAAqB,EAAE,UAAU,SAAgC,EAAE,EAAa;AAC9F,QAAO,gBACL;EACE,GAAG;EACH,OAAO,EAAE,OAAO,EAAE,EAAE;EACpB,eAAe,EAAE;EAClB,EACD,EAAE,SAAS,CACZ;;;AAIH,MAAa,mBAA8B,sBAAsB"}
@@ -87,7 +87,10 @@ type CollectionStore = {
87
87
  getFilterRemovalUrl(filter: ProductFilter): string;
88
88
  /**
89
89
  * Parses `FormData` from the submitted form into collection params and applies them.
90
- * Callers must call `event.preventDefault()` before invoking this method.
90
+ * Doesn't cancel the event: call `event.preventDefault()` first, or the browser
91
+ * submits the form natively and navigates away.
92
+ *
93
+ * @throws {TypeError} If `event.target` is not an `HTMLFormElement`.
91
94
  */
92
95
  handleFormSubmit(event: SubmitEvent): void;
93
96
  /**
@@ -107,6 +110,8 @@ type CollectionStore = {
107
110
  */
108
111
  setSortByValue(sortByValue: string): void;
109
112
  };
113
+ /** Mutation methods exposed by the collection store. */
114
+ type CollectionActions = Pick<CollectionStore, "setFilters" | "toggleFilter" | "toggleFilterInput" | "setSortKey" | "setSortByValue" | "reset" | "handleFormSubmit">;
110
115
  /** Options for creating a new {@link CollectionStore}. */
111
116
  type CreateCollectionStoreOptions = {
112
117
  /** Collection metadata from the framework loader. */data: CollectionData;
@@ -127,8 +132,19 @@ type CreateCollectionStoreOptions = {
127
132
  *
128
133
  * The store manages browse intent (filters, sort) only.
129
134
  * Server response data lives in the framework's loader.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * const store = createCollectionStore({
139
+ * data: loaderData.collection,
140
+ * urlSearch: window.location.search,
141
+ * onBrowseChange: () => navigate({ search: store.serializeToParams().toString() }),
142
+ * });
143
+ *
144
+ * store.subscribe((state) => renderFilters(state.filters));
145
+ * ```
130
146
  */
131
147
  declare function createCollectionStore(options: CreateCollectionStoreOptions): CollectionStore;
132
148
  //#endregion
133
- export { CollectionData, CollectionStore, CreateCollectionStoreOptions, createCollectionStore };
149
+ export { CollectionActions, CollectionData, CollectionStore, CreateCollectionStoreOptions, createCollectionStore };
134
150
  //# sourceMappingURL=collection.d.mts.map
@@ -8,6 +8,17 @@ import { collectionParamsMatchState, filterEquals, getFilterRemovalUrl, isDirect
8
8
  *
9
9
  * The store manages browse intent (filters, sort) only.
10
10
  * Server response data lives in the framework's loader.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const store = createCollectionStore({
15
+ * data: loaderData.collection,
16
+ * urlSearch: window.location.search,
17
+ * onBrowseChange: () => navigate({ search: store.serializeToParams().toString() }),
18
+ * });
19
+ *
20
+ * store.subscribe((state) => renderFilters(state.filters));
21
+ * ```
11
22
  */
12
23
  function createCollectionStore(options) {
13
24
  const context = {
@@ -1 +1 @@
1
- {"version":3,"file":"collection.mjs","names":[],"sources":["../../../../src/core/collection/collection.ts"],"sourcesContent":["import type { ProductCollectionSortKeys } from \"../../graphql/generated/storefront-api-types\";\nimport { createObservable } from \"../observable\";\nimport type { CollectionState, ProductFilter } from \"./state\";\nimport { createInitialCollectionState } from \"./state\";\nimport {\n collectionParamsMatchState,\n filterEquals,\n getFilterRemovalUrl,\n isDirectionalSortKey,\n parseCollectionParams,\n parseSortByValue,\n serializeCollectionParams,\n} from \"./url\";\n\n/** Snapshot from the framework loader for a single collection fetch. */\nexport type CollectionData = {\n /** URL-safe collection slug (e.g. `\"shoes\"`). */\n handle: string;\n /**\n * Search string this data snapshot was fetched for (with or without leading `?`).\n * Compared against live {@link CreateCollectionStoreOptions.urlSearch} before settling.\n */\n dataSearch: string;\n};\n\n/**\n * A reactive, framework-agnostic store for collection browse state.\n *\n * Manages filters and sorting — the user's *intent*. Server response\n * data (products, productsCount, availableFilters) lives in the framework's\n * loader data, not here.\n *\n * Mutations are synchronous state changes that set `status: \"loading\"`.\n * The framework adapter navigates (URL change), the framework loader re-runs,\n * and the adapter calls {@link settle} when fresh data arrives.\n *\n * @example\n * ```ts\n * const store = createCollectionStore({\n * data: { handle: \"shoes\", dataSearch: \"\" },\n * });\n *\n * store.setFilters([{ tag: \"sale\" }]); // status → \"loading\"\n * // ...framework fetches...\n * store.settle(); // status → \"idle\"\n * ```\n */\nexport type CollectionStore = {\n /** Returns the current snapshot of collection browse state. */\n getState(): CollectionState;\n\n /**\n * Registers a listener invoked on every state change. Returns an unsubscribe function.\n * @param listener - Callback receiving the new state snapshot\n */\n subscribe(listener: (state: CollectionState) => void): () => void;\n\n /**\n * Replaces the active product filters.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n setFilters(filters: ProductFilter[]): void;\n\n /**\n * Adds the filter if not active, removes it if already active.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n toggleFilter(filter: ProductFilter): void;\n\n /**\n * Changes the sort order.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n setSortKey(sortKey: ProductCollectionSortKeys, reverse?: boolean): void;\n\n /**\n * Resets filters and sort to defaults.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n reset(): void;\n\n /**\n * Returns `true` when URL params and current browse state describe the same filters and sort.\n */\n matchesParams(searchParams: URLSearchParams): boolean;\n\n /**\n * Applies URL search params to the store when they differ from current state.\n * Used when the framework router reports an external URL change (back/forward).\n */\n syncFromParams(searchParams: URLSearchParams): void;\n\n /**\n * Signals that the framework fetch completed and fresh data is available.\n * Resets `status` to `\"idle\"`.\n */\n settle(): void;\n\n /**\n * Serializes the store's current filter/sort state into URL search params.\n * Only includes store-owned keys (`filter.*`, `sort_by`).\n */\n serializeToParams(): URLSearchParams;\n\n /**\n * Builds a URL string that removes the given filter from the current params.\n * Useful for rendering \"remove filter\" links/buttons.\n */\n getFilterRemovalUrl(filter: ProductFilter): string;\n\n /**\n * Parses `FormData` from the submitted form into collection params and applies them.\n * Callers must call `event.preventDefault()` before invoking this method.\n */\n handleFormSubmit(event: SubmitEvent): void;\n\n /**\n * Replaces the callback that runs after user filter/sort changes. Framework\n * adapters use this to trigger navigation. Pass `null` to remove.\n */\n setOnBrowseChange(callback: (() => void) | null): void;\n\n /**\n * Parses a Storefront API filter input JSON string (e.g. from\n * `FilterValue.input`) and toggles the resulting filter.\n * No-op if `input` is not valid JSON.\n */\n toggleFilterInput(input: string): void;\n\n /**\n * Parses a Liquid-compatible `sort_by` value (e.g. `\"price-ascending\"`) and\n * applies the corresponding sort key and direction.\n */\n setSortByValue(sortByValue: string): void;\n};\n\n/** Options for creating a new {@link CollectionStore}. */\nexport type CreateCollectionStoreOptions = {\n /** Collection metadata from the framework loader. */\n data: CollectionData;\n /**\n * Live URL search string from the framework router (with or without leading `?`).\n * Falls back to `data.dataSearch` when omitted.\n */\n urlSearch?: string;\n /**\n * Called after user-initiated filter/sort changes.\n * Framework adapters use this to trigger navigation. Not called by\n * {@link CollectionStore.syncFromParams} or {@link CollectionStore.settle}.\n */\n onBrowseChange?: () => void;\n};\n\ntype CollectionStoreContext = {\n observable: ReturnType<typeof createObservable<CollectionState>>;\n handle: string;\n onBrowseChange: (() => void) | null;\n};\n\n/**\n * Creates a new collection store for the given collection handle.\n *\n * The store manages browse intent (filters, sort) only.\n * Server response data lives in the framework's loader.\n */\nexport function createCollectionStore(options: CreateCollectionStoreOptions): CollectionStore {\n const initialState = buildInitialState(options);\n\n const context: CollectionStoreContext = {\n observable: createObservable<CollectionState>(initialState),\n handle: options.data.handle,\n onBrowseChange: options.onBrowseChange ?? null,\n };\n\n return {\n getState: () => context.observable.state,\n subscribe: (listener) => context.observable.subscribe(listener),\n setFilters: (filters) => applyBrowseChange(context, { filters: normalizeFilters(filters) }),\n toggleFilter: (filter) => toggleFilter(context, filter),\n setSortKey: (sortKey, reverse) =>\n applyBrowseChange(context, { sortKey, reverse: reverse ?? false }),\n reset: () => resetStore(context),\n matchesParams: (searchParams) => matchesParams(context, searchParams),\n syncFromParams: (searchParams) => syncFromParams(context, searchParams),\n settle: () => settleStore(context),\n serializeToParams: () => serializeCollectionParams(context.observable.state),\n getFilterRemovalUrl: (filter) =>\n getFilterRemovalUrl(serializeCollectionParams(context.observable.state), filter),\n handleFormSubmit: (event) => handleFormSubmit(context, event),\n setOnBrowseChange: (callback) => {\n context.onBrowseChange = callback;\n },\n toggleFilterInput: (input) => toggleFilterInput(context, input),\n setSortByValue: (sortByValue) => setSortByValue(context, sortByValue),\n };\n}\n\nfunction buildInitialState(options: CreateCollectionStoreOptions): CollectionState {\n const base = createInitialCollectionState(options.data.handle);\n\n const search = options.urlSearch ?? options.data.dataSearch;\n if (search) {\n const parsed = parseCollectionParams(new URLSearchParams(search));\n base.filters = parsed.filters;\n base.sortKey = parsed.sortKey;\n base.reverse = normalizeReverse(parsed.sortKey, parsed.reverse);\n }\n\n return base;\n}\n\ntype BrowseChange = Partial<Pick<CollectionState, \"filters\" | \"sortKey\" | \"reverse\">>;\n\n/**\n * Drops `reverse` for sort keys that do not support direction.\n * Only `PRICE`, `TITLE`, `CREATED`, and `ID` honor ascending/descending.\n *\n * @example normalizeReverse(\"BEST_SELLING\", true) // → false\n * @example normalizeReverse(\"PRICE\", true) // → true\n */\nfunction normalizeReverse(\n sortKey: ProductCollectionSortKeys | undefined,\n reverse: boolean,\n): boolean {\n if (!sortKey || !isDirectionalSortKey(sortKey)) return false;\n return reverse;\n}\n\nfunction applyBrowseChange(\n context: CollectionStoreContext,\n change: BrowseChange,\n notify = true,\n): void {\n context.observable.setState((prev) => {\n const merged = { ...prev, ...change, status: \"loading\" as const };\n merged.reverse = normalizeReverse(merged.sortKey, merged.reverse);\n return merged;\n });\n if (notify) {\n context.onBrowseChange?.();\n }\n}\n\nfunction toggleFilter(context: CollectionStoreContext, filter: ProductFilter): void {\n const current = context.observable.state.filters;\n const isActive = current.some((f) => filterEquals(f, filter));\n\n const next = isActive\n ? current.filter((f) => !filterEquals(f, filter))\n : normalizeFilters([...current, filter]);\n\n applyBrowseChange(context, { filters: next });\n}\n\nfunction normalizeFilters(filters: ProductFilter[]): ProductFilter[] {\n const normalized: ProductFilter[] = [];\n\n for (const filter of filters) {\n if (filter.available != null) {\n // Availability is a single boolean facet. Duplicate availability params\n // (filter.v.availability=1&filter.v.availability=0) mean no filter, so\n // keeping both values here prevents the provider from settling navigation.\n const existingAvailabilityIndex = normalized.findIndex((candidate) => {\n return candidate.available != null;\n });\n\n if (existingAvailabilityIndex !== -1) {\n normalized.splice(existingAvailabilityIndex, 1);\n continue;\n }\n }\n\n normalized.push(filter);\n }\n\n return normalized;\n}\n\nfunction resetStore(context: CollectionStoreContext): void {\n const initial = createInitialCollectionState(context.handle);\n applyBrowseChange(context, {\n filters: initial.filters,\n sortKey: initial.sortKey,\n reverse: initial.reverse,\n });\n}\n\nfunction matchesParams(context: CollectionStoreContext, searchParams: URLSearchParams): boolean {\n return collectionParamsMatchState(searchParams, context.observable.state);\n}\n\nfunction syncFromParams(context: CollectionStoreContext, searchParams: URLSearchParams): void {\n if (matchesParams(context, searchParams)) return;\n\n const parsed = parseCollectionParams(searchParams);\n\n // URL caught up to an in-flight browse change — align state without re-entering loading.\n if (context.observable.state.status === \"loading\") {\n context.observable.setState((prev) => ({\n ...prev,\n filters: parsed.filters,\n sortKey: parsed.sortKey,\n reverse: normalizeReverse(parsed.sortKey, parsed.reverse),\n status: \"loading\",\n }));\n return;\n }\n\n /**\n * URL already changes, so we don't need to notify to call onBrowseChange\n * again which would cause a double navigate.\n */\n applyBrowseChange(\n context,\n {\n filters: parsed.filters,\n sortKey: parsed.sortKey,\n reverse: parsed.reverse,\n },\n false,\n );\n}\n\nfunction settleStore(context: CollectionStoreContext): void {\n context.observable.setState((prev) => {\n if (prev.status === \"idle\") return prev;\n return { ...prev, status: \"idle\" };\n });\n}\n\nfunction handleFormSubmit(context: CollectionStoreContext, event: SubmitEvent): void {\n if (!(event.target instanceof HTMLFormElement)) {\n throw new TypeError(`Expected event.target to be an HTMLFormElement, got ${event.target}`);\n }\n\n const formData = new FormData(event.target);\n const formParams = new URLSearchParams();\n\n for (const [key, value] of formData.entries()) {\n if (typeof value === \"string\") {\n formParams.append(key, value);\n }\n }\n\n const parsed = parseCollectionParams(formParams);\n\n const change: BrowseChange = {\n filters: normalizeFilters(parsed.filters),\n };\n\n if (formParams.has(\"sort_by\")) {\n change.sortKey = parsed.sortKey;\n change.reverse = parsed.reverse;\n }\n\n applyBrowseChange(context, change);\n}\n\nfunction toggleFilterInput(context: CollectionStoreContext, input: string): void {\n let parsed: ProductFilter;\n try {\n parsed = JSON.parse(input) as ProductFilter;\n } catch {\n return;\n }\n toggleFilter(context, parsed);\n}\n\nfunction setSortByValue(context: CollectionStoreContext, sortByValue: string): void {\n const { sortKey, reverse } = parseSortByValue(sortByValue);\n applyBrowseChange(context, {\n sortKey: sortKey ?? \"COLLECTION_DEFAULT\",\n reverse: reverse ?? false,\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"collection.mjs","names":[],"sources":["../../../../src/core/collection/collection.ts"],"sourcesContent":["import type { ProductCollectionSortKeys } from \"../../graphql/generated/storefront-api-types\";\nimport { createObservable } from \"../observable\";\nimport type { CollectionState, ProductFilter } from \"./state\";\nimport { createInitialCollectionState } from \"./state\";\nimport {\n collectionParamsMatchState,\n filterEquals,\n getFilterRemovalUrl,\n isDirectionalSortKey,\n parseCollectionParams,\n parseSortByValue,\n serializeCollectionParams,\n} from \"./url\";\n\n/** Snapshot from the framework loader for a single collection fetch. */\nexport type CollectionData = {\n /** URL-safe collection slug (e.g. `\"shoes\"`). */\n handle: string;\n /**\n * Search string this data snapshot was fetched for (with or without leading `?`).\n * Compared against live {@link CreateCollectionStoreOptions.urlSearch} before settling.\n */\n dataSearch: string;\n};\n\n/**\n * A reactive, framework-agnostic store for collection browse state.\n *\n * Manages filters and sorting — the user's *intent*. Server response\n * data (products, productsCount, availableFilters) lives in the framework's\n * loader data, not here.\n *\n * Mutations are synchronous state changes that set `status: \"loading\"`.\n * The framework adapter navigates (URL change), the framework loader re-runs,\n * and the adapter calls {@link settle} when fresh data arrives.\n *\n * @example\n * ```ts\n * const store = createCollectionStore({\n * data: { handle: \"shoes\", dataSearch: \"\" },\n * });\n *\n * store.setFilters([{ tag: \"sale\" }]); // status → \"loading\"\n * // ...framework fetches...\n * store.settle(); // status → \"idle\"\n * ```\n */\nexport type CollectionStore = {\n /** Returns the current snapshot of collection browse state. */\n getState(): CollectionState;\n\n /**\n * Registers a listener invoked on every state change. Returns an unsubscribe function.\n * @param listener - Callback receiving the new state snapshot\n */\n subscribe(listener: (state: CollectionState) => void): () => void;\n\n /**\n * Replaces the active product filters.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n setFilters(filters: ProductFilter[]): void;\n\n /**\n * Adds the filter if not active, removes it if already active.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n toggleFilter(filter: ProductFilter): void;\n\n /**\n * Changes the sort order.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n setSortKey(sortKey: ProductCollectionSortKeys, reverse?: boolean): void;\n\n /**\n * Resets filters and sort to defaults.\n * Sets `status: \"loading\"` — call {@link settle} when the framework fetch completes.\n */\n reset(): void;\n\n /**\n * Returns `true` when URL params and current browse state describe the same filters and sort.\n */\n matchesParams(searchParams: URLSearchParams): boolean;\n\n /**\n * Applies URL search params to the store when they differ from current state.\n * Used when the framework router reports an external URL change (back/forward).\n */\n syncFromParams(searchParams: URLSearchParams): void;\n\n /**\n * Signals that the framework fetch completed and fresh data is available.\n * Resets `status` to `\"idle\"`.\n */\n settle(): void;\n\n /**\n * Serializes the store's current filter/sort state into URL search params.\n * Only includes store-owned keys (`filter.*`, `sort_by`).\n */\n serializeToParams(): URLSearchParams;\n\n /**\n * Builds a URL string that removes the given filter from the current params.\n * Useful for rendering \"remove filter\" links/buttons.\n */\n getFilterRemovalUrl(filter: ProductFilter): string;\n\n /**\n * Parses `FormData` from the submitted form into collection params and applies them.\n * Doesn't cancel the event: call `event.preventDefault()` first, or the browser\n * submits the form natively and navigates away.\n *\n * @throws {TypeError} If `event.target` is not an `HTMLFormElement`.\n */\n handleFormSubmit(event: SubmitEvent): void;\n\n /**\n * Replaces the callback that runs after user filter/sort changes. Framework\n * adapters use this to trigger navigation. Pass `null` to remove.\n */\n setOnBrowseChange(callback: (() => void) | null): void;\n\n /**\n * Parses a Storefront API filter input JSON string (e.g. from\n * `FilterValue.input`) and toggles the resulting filter.\n * No-op if `input` is not valid JSON.\n */\n toggleFilterInput(input: string): void;\n\n /**\n * Parses a Liquid-compatible `sort_by` value (e.g. `\"price-ascending\"`) and\n * applies the corresponding sort key and direction.\n */\n setSortByValue(sortByValue: string): void;\n};\n\n/** Mutation methods exposed by the collection store. */\nexport type CollectionActions = Pick<\n CollectionStore,\n | \"setFilters\"\n | \"toggleFilter\"\n | \"toggleFilterInput\"\n | \"setSortKey\"\n | \"setSortByValue\"\n | \"reset\"\n | \"handleFormSubmit\"\n>;\n\n/** Options for creating a new {@link CollectionStore}. */\nexport type CreateCollectionStoreOptions = {\n /** Collection metadata from the framework loader. */\n data: CollectionData;\n /**\n * Live URL search string from the framework router (with or without leading `?`).\n * Falls back to `data.dataSearch` when omitted.\n */\n urlSearch?: string;\n /**\n * Called after user-initiated filter/sort changes.\n * Framework adapters use this to trigger navigation. Not called by\n * {@link CollectionStore.syncFromParams} or {@link CollectionStore.settle}.\n */\n onBrowseChange?: () => void;\n};\n\ntype CollectionStoreContext = {\n observable: ReturnType<typeof createObservable<CollectionState>>;\n handle: string;\n onBrowseChange: (() => void) | null;\n};\n\n/**\n * Creates a new collection store for the given collection handle.\n *\n * The store manages browse intent (filters, sort) only.\n * Server response data lives in the framework's loader.\n *\n * @example\n * ```ts\n * const store = createCollectionStore({\n * data: loaderData.collection,\n * urlSearch: window.location.search,\n * onBrowseChange: () => navigate({ search: store.serializeToParams().toString() }),\n * });\n *\n * store.subscribe((state) => renderFilters(state.filters));\n * ```\n */\nexport function createCollectionStore(options: CreateCollectionStoreOptions): CollectionStore {\n const initialState = buildInitialState(options);\n\n const context: CollectionStoreContext = {\n observable: createObservable<CollectionState>(initialState),\n handle: options.data.handle,\n onBrowseChange: options.onBrowseChange ?? null,\n };\n\n return {\n getState: () => context.observable.state,\n subscribe: (listener) => context.observable.subscribe(listener),\n setFilters: (filters) => applyBrowseChange(context, { filters: normalizeFilters(filters) }),\n toggleFilter: (filter) => toggleFilter(context, filter),\n setSortKey: (sortKey, reverse) =>\n applyBrowseChange(context, { sortKey, reverse: reverse ?? false }),\n reset: () => resetStore(context),\n matchesParams: (searchParams) => matchesParams(context, searchParams),\n syncFromParams: (searchParams) => syncFromParams(context, searchParams),\n settle: () => settleStore(context),\n serializeToParams: () => serializeCollectionParams(context.observable.state),\n getFilterRemovalUrl: (filter) =>\n getFilterRemovalUrl(serializeCollectionParams(context.observable.state), filter),\n handleFormSubmit: (event) => handleFormSubmit(context, event),\n setOnBrowseChange: (callback) => {\n context.onBrowseChange = callback;\n },\n toggleFilterInput: (input) => toggleFilterInput(context, input),\n setSortByValue: (sortByValue) => setSortByValue(context, sortByValue),\n };\n}\n\nfunction buildInitialState(options: CreateCollectionStoreOptions): CollectionState {\n const base = createInitialCollectionState(options.data.handle);\n\n const search = options.urlSearch ?? options.data.dataSearch;\n if (search) {\n const parsed = parseCollectionParams(new URLSearchParams(search));\n base.filters = parsed.filters;\n base.sortKey = parsed.sortKey;\n base.reverse = normalizeReverse(parsed.sortKey, parsed.reverse);\n }\n\n return base;\n}\n\ntype BrowseChange = Partial<Pick<CollectionState, \"filters\" | \"sortKey\" | \"reverse\">>;\n\n/**\n * Drops `reverse` for sort keys that do not support direction.\n * Only `PRICE`, `TITLE`, `CREATED`, and `ID` honor ascending/descending.\n *\n * @example normalizeReverse(\"BEST_SELLING\", true) // → false\n * @example normalizeReverse(\"PRICE\", true) // → true\n */\nfunction normalizeReverse(\n sortKey: ProductCollectionSortKeys | undefined,\n reverse: boolean,\n): boolean {\n if (!sortKey || !isDirectionalSortKey(sortKey)) return false;\n return reverse;\n}\n\nfunction applyBrowseChange(\n context: CollectionStoreContext,\n change: BrowseChange,\n notify = true,\n): void {\n context.observable.setState((prev) => {\n const merged = { ...prev, ...change, status: \"loading\" as const };\n merged.reverse = normalizeReverse(merged.sortKey, merged.reverse);\n return merged;\n });\n if (notify) {\n context.onBrowseChange?.();\n }\n}\n\nfunction toggleFilter(context: CollectionStoreContext, filter: ProductFilter): void {\n const current = context.observable.state.filters;\n const isActive = current.some((f) => filterEquals(f, filter));\n\n const next = isActive\n ? current.filter((f) => !filterEquals(f, filter))\n : normalizeFilters([...current, filter]);\n\n applyBrowseChange(context, { filters: next });\n}\n\nfunction normalizeFilters(filters: ProductFilter[]): ProductFilter[] {\n const normalized: ProductFilter[] = [];\n\n for (const filter of filters) {\n if (filter.available != null) {\n // Availability is a single boolean facet. Duplicate availability params\n // (filter.v.availability=1&filter.v.availability=0) mean no filter, so\n // keeping both values here prevents the provider from settling navigation.\n const existingAvailabilityIndex = normalized.findIndex((candidate) => {\n return candidate.available != null;\n });\n\n if (existingAvailabilityIndex !== -1) {\n normalized.splice(existingAvailabilityIndex, 1);\n continue;\n }\n }\n\n normalized.push(filter);\n }\n\n return normalized;\n}\n\nfunction resetStore(context: CollectionStoreContext): void {\n const initial = createInitialCollectionState(context.handle);\n applyBrowseChange(context, {\n filters: initial.filters,\n sortKey: initial.sortKey,\n reverse: initial.reverse,\n });\n}\n\nfunction matchesParams(context: CollectionStoreContext, searchParams: URLSearchParams): boolean {\n return collectionParamsMatchState(searchParams, context.observable.state);\n}\n\nfunction syncFromParams(context: CollectionStoreContext, searchParams: URLSearchParams): void {\n if (matchesParams(context, searchParams)) return;\n\n const parsed = parseCollectionParams(searchParams);\n\n // URL caught up to an in-flight browse change — align state without re-entering loading.\n if (context.observable.state.status === \"loading\") {\n context.observable.setState((prev) => ({\n ...prev,\n filters: parsed.filters,\n sortKey: parsed.sortKey,\n reverse: normalizeReverse(parsed.sortKey, parsed.reverse),\n status: \"loading\",\n }));\n return;\n }\n\n /**\n * URL already changes, so we don't need to notify to call onBrowseChange\n * again which would cause a double navigate.\n */\n applyBrowseChange(\n context,\n {\n filters: parsed.filters,\n sortKey: parsed.sortKey,\n reverse: parsed.reverse,\n },\n false,\n );\n}\n\nfunction settleStore(context: CollectionStoreContext): void {\n context.observable.setState((prev) => {\n if (prev.status === \"idle\") return prev;\n return { ...prev, status: \"idle\" };\n });\n}\n\nfunction handleFormSubmit(context: CollectionStoreContext, event: SubmitEvent): void {\n if (!(event.target instanceof HTMLFormElement)) {\n throw new TypeError(`Expected event.target to be an HTMLFormElement, got ${event.target}`);\n }\n\n const formData = new FormData(event.target);\n const formParams = new URLSearchParams();\n\n for (const [key, value] of formData.entries()) {\n if (typeof value === \"string\") {\n formParams.append(key, value);\n }\n }\n\n const parsed = parseCollectionParams(formParams);\n\n const change: BrowseChange = {\n filters: normalizeFilters(parsed.filters),\n };\n\n if (formParams.has(\"sort_by\")) {\n change.sortKey = parsed.sortKey;\n change.reverse = parsed.reverse;\n }\n\n applyBrowseChange(context, change);\n}\n\nfunction toggleFilterInput(context: CollectionStoreContext, input: string): void {\n let parsed: ProductFilter;\n try {\n parsed = JSON.parse(input) as ProductFilter;\n } catch {\n return;\n }\n toggleFilter(context, parsed);\n}\n\nfunction setSortByValue(context: CollectionStoreContext, sortByValue: string): void {\n const { sortKey, reverse } = parseSortByValue(sortByValue);\n applyBrowseChange(context, {\n sortKey: sortKey ?? \"COLLECTION_DEFAULT\",\n reverse: reverse ?? false,\n });\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { ProductCollectionSortKeys } from "../../graphql/generated/storefront-api-types.mjs";
2
2
  import { ProductFilter } from "../../vendor/standard-events.mjs";
3
3
  import { AvailableFilter, AvailableFilterValue, CollectionState, FilterPresentation, FilterType, createInitialCollectionState } from "./state.mjs";
4
- import { CollectionData, CollectionStore, CreateCollectionStoreOptions, createCollectionStore } from "./collection.mjs";
4
+ import { CollectionActions, CollectionData, CollectionStore, CreateCollectionStoreOptions, createCollectionStore } from "./collection.mjs";
5
5
  import { CollectionReconciler, ReconcilerCallbacks, createCollectionReconciler } from "./reconciler.mjs";
6
6
  import { CollectionParams, filterEquals, getFilterRemovalUrl, getSortByValue, isFilterInputActive, normalizeCollectionSearch, parseCollectionParams, parseSortByValue, serializeCollectionParams } from "./url.mjs";
@@ -1,15 +1,24 @@
1
1
  import { CollectionStore } from "./collection.mjs";
2
2
 
3
3
  //#region src/core/collection/reconciler.d.ts
4
+ /**
5
+ * Adapter-supplied hooks that let the reconciler read framework state and
6
+ * push navigation side-effects without depending on any specific framework.
7
+ */
4
8
  type ReconcilerCallbacks = {
5
- getStore: () => CollectionStore;
6
- readUrlSearch: () => string;
9
+ /** Returns the current collection store instance. */getStore: () => CollectionStore; /** Reads the live URL search string from the framework router. */
10
+ readUrlSearch: () => string; /** Delivers a serialized search string for the framework adapter to handle. */
7
11
  emitChange: (searchString: string) => void;
8
12
  };
13
+ /**
14
+ * State machine that keeps the URL, server data, and `CollectionStore` in
15
+ * sync during browse-change chains. Created by `createCollectionReconciler`.
16
+ */
9
17
  type CollectionReconciler = {
10
18
  /**
11
- * URL ↔ store reconciliation state machine. Call whenever urlSearch,
12
- * dataSearch, or store state changes.
19
+ * URL ↔ store reconciliation state machine. Call whenever `urlSearch` or
20
+ * `dataSearch` changes. Store changes go through
21
+ * {@link CollectionReconciler.handleBrowseChange} instead.
13
22
  */
14
23
  reconcile(urlSearch: string, dataSearch: string): void;
15
24
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"reconciler.mjs","names":[],"sources":["../../../../src/core/collection/reconciler.ts"],"sourcesContent":["import type { CollectionStore } from \"./collection\";\nimport { collectionSearchEqual, mergeCollectionParams, normalizeCollectionSearch } from \"./url\";\n\nexport type ReconcilerCallbacks = {\n getStore: () => CollectionStore;\n readUrlSearch: () => string;\n emitChange: (searchString: string) => void;\n};\n\nexport type CollectionReconciler = {\n /**\n * URL ↔ store reconciliation state machine. Call whenever urlSearch,\n * dataSearch, or store state changes.\n */\n reconcile(urlSearch: string, dataSearch: string): void;\n /**\n * Callback for {@link CollectionStore.setOnBrowseChange}. Merges current\n * URL params with store state, tracks the pending navigation, and emits.\n */\n handleBrowseChange(): void;\n /** Clears pending navigation state (call when the store is recreated). */\n reset(newPrevUrlSearch: string): void;\n};\n\n/**\n * Creates a framework-agnostic reconciler that manages the state machine for\n * keeping the URL, server data, and store in sync during browse-change chains.\n *\n * Framework adapters delegate to this so the reconciliation logic\n * lives in one place.\n */\nexport function createCollectionReconciler(\n callbacks: ReconcilerCallbacks,\n initialPrevUrlSearch = \"\",\n): CollectionReconciler {\n let pendingFilter: string | null = null;\n let pendingUrls = new Set<string>();\n let prevUrlSearch = initialPrevUrlSearch;\n\n return {\n handleBrowseChange() {\n const store = callbacks.getStore();\n const merged = mergeCollectionParams(\n new URLSearchParams(callbacks.readUrlSearch()),\n store.getState(),\n );\n const searchString = merged.toString();\n pendingFilter = searchString;\n pendingUrls.add(searchString);\n callbacks.emitChange(searchString ? `?${searchString}` : \"\");\n },\n\n reset(newPrevUrlSearch: string) {\n pendingFilter = null;\n pendingUrls = new Set();\n prevUrlSearch = newPrevUrlSearch;\n },\n\n // State machine — four states:\n //\n // ┌─────────────────────────────────────────────────────────────────────┐\n // │ State A: No pending navigation (pendingFilter === null) │\n // │ • URL matches store → try settle if server data caught up │\n // │ • URL differs from store → syncFromParams, then try settle │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State B: Pending — URL matches target (pendingTargetMatchesUrl) │\n // │ • Server data matches URL → settle, clear pending state │\n // │ • Server data stale (intermediate) → re-dispatch to target │\n // │ • Server data not yet arrived → wait (keep pending set) │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State C: Pending — URL is intermediate (in pendingUrls) │\n // │ • Ignore URL change; if router settled here with server data │\n // │ confirming the intermediate, re-dispatch to the real target │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State D: Pending — URL is unknown (not in pendingUrls) │\n // │ • External navigation (back/forward, <Link>) │\n // │ • Abandon pending state, fall through to State A │\n // └─────────────────────────────────────────────────────────────────────┘\n //\n // Rapid toggles (A on → B on → A off) produce multiple in-flight navigations.\n // pendingFilter tracks the latest target; pendingUrls tracks every URL\n // dispatched in the chain so intermediates can be distinguished from external navs.\n reconcile(urlSearch: string, dataSearch: string) {\n const store = callbacks.getStore();\n\n const pendingSearchEquals = (search: string): boolean =>\n [...pendingUrls].some((pending) => collectionSearchEqual(pending, search));\n\n const pendingTargetMatchesUrl =\n pendingFilter != null && collectionSearchEqual(pendingFilter, urlSearch);\n\n const trySettleFromServer = (): boolean => {\n if (store.getState().status !== \"loading\") return false;\n if (!collectionSearchEqual(dataSearch, urlSearch)) return false;\n\n const incomingParams = new URLSearchParams(urlSearch);\n if (!store.matchesParams(incomingParams)) {\n store.syncFromParams(incomingParams);\n }\n\n store.settle();\n return true;\n };\n\n const clearPendingIfSettled = (): boolean => {\n if (!trySettleFromServer()) return false;\n pendingFilter = null;\n pendingUrls.clear();\n return true;\n };\n\n if (clearPendingIfSettled()) return;\n\n const normalizedUrlSearch = normalizeCollectionSearch(urlSearch);\n const normalizedPrevUrlSearch = normalizeCollectionSearch(prevUrlSearch);\n const urlSearchChanged = normalizedUrlSearch !== normalizedPrevUrlSearch;\n prevUrlSearch = urlSearch;\n\n const reDispatchToPendingTarget = () => {\n if (pendingFilter == null) return;\n callbacks.emitChange(pendingFilter ? `?${pendingFilter}` : \"\");\n };\n\n const routerSettledOnIntermediateWithStaleTarget =\n pendingFilter != null &&\n pendingSearchEquals(urlSearch) &&\n !pendingTargetMatchesUrl &&\n collectionSearchEqual(dataSearch, urlSearch);\n\n const serverConfirmedIntermediateWhileTargeting =\n pendingFilter != null &&\n pendingSearchEquals(dataSearch) &&\n !collectionSearchEqual(dataSearch, pendingFilter) &&\n !collectionSearchEqual(dataSearch, urlSearch);\n\n if (pendingTargetMatchesUrl) {\n if (trySettleFromServer()) {\n pendingFilter = null;\n pendingUrls.clear();\n } else if (\n store.getState().status === \"loading\" &&\n serverConfirmedIntermediateWhileTargeting\n ) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n if (pendingFilter != null) {\n if (!urlSearchChanged) {\n if (clearPendingIfSettled()) return;\n if (routerSettledOnIntermediateWithStaleTarget) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n if (pendingSearchEquals(urlSearch)) {\n if (clearPendingIfSettled()) return;\n if (routerSettledOnIntermediateWithStaleTarget) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n pendingFilter = null;\n pendingUrls.clear();\n }\n\n const incomingParams = new URLSearchParams(urlSearch);\n\n if (store.matchesParams(incomingParams)) {\n trySettleFromServer();\n return;\n }\n\n store.syncFromParams(incomingParams);\n trySettleFromServer();\n },\n };\n}\n"],"mappings":";;;;;;;;;;AA+BA,SAAgB,2BACd,WACA,uBAAuB,IACD;CACtB,IAAI,gBAA+B;CACnC,IAAI,8BAAc,IAAI,KAAa;CACnC,IAAI,gBAAgB;AAEpB,QAAO;EACL,qBAAqB;GACnB,MAAM,QAAQ,UAAU,UAAU;GAKlC,MAAM,eAJS,sBACb,IAAI,gBAAgB,UAAU,eAAe,CAAC,EAC9C,MAAM,UAAU,CAES,CAAC,UAAU;AACtC,mBAAgB;AAChB,eAAY,IAAI,aAAa;AAC7B,aAAU,WAAW,eAAe,IAAI,iBAAiB,GAAG;;EAG9D,MAAM,kBAA0B;AAC9B,mBAAgB;AAChB,iCAAc,IAAI,KAAK;AACvB,mBAAgB;;EA2BlB,UAAU,WAAmB,YAAoB;GAC/C,MAAM,QAAQ,UAAU,UAAU;GAElC,MAAM,uBAAuB,WAC3B,CAAC,GAAG,YAAY,CAAC,MAAM,YAAY,sBAAsB,SAAS,OAAO,CAAC;GAE5E,MAAM,0BACJ,iBAAiB,QAAQ,sBAAsB,eAAe,UAAU;GAE1E,MAAM,4BAAqC;AACzC,QAAI,MAAM,UAAU,CAAC,WAAW,UAAW,QAAO;AAClD,QAAI,CAAC,sBAAsB,YAAY,UAAU,CAAE,QAAO;IAE1D,MAAM,iBAAiB,IAAI,gBAAgB,UAAU;AACrD,QAAI,CAAC,MAAM,cAAc,eAAe,CACtC,OAAM,eAAe,eAAe;AAGtC,UAAM,QAAQ;AACd,WAAO;;GAGT,MAAM,8BAAuC;AAC3C,QAAI,CAAC,qBAAqB,CAAE,QAAO;AACnC,oBAAgB;AAChB,gBAAY,OAAO;AACnB,WAAO;;AAGT,OAAI,uBAAuB,CAAE;GAI7B,MAAM,mBAFsB,0BAA0B,UAEV,KADZ,0BAA0B,cACc;AACxE,mBAAgB;GAEhB,MAAM,kCAAkC;AACtC,QAAI,iBAAiB,KAAM;AAC3B,cAAU,WAAW,gBAAgB,IAAI,kBAAkB,GAAG;;GAGhE,MAAM,6CACJ,iBAAiB,QACjB,oBAAoB,UAAU,IAC9B,CAAC,2BACD,sBAAsB,YAAY,UAAU;GAE9C,MAAM,4CACJ,iBAAiB,QACjB,oBAAoB,WAAW,IAC/B,CAAC,sBAAsB,YAAY,cAAc,IACjD,CAAC,sBAAsB,YAAY,UAAU;AAE/C,OAAI,yBAAyB;AAC3B,QAAI,qBAAqB,EAAE;AACzB,qBAAgB;AAChB,iBAAY,OAAO;eAEnB,MAAM,UAAU,CAAC,WAAW,aAC5B,0CAEA,4BAA2B;AAE7B;;AAGF,OAAI,iBAAiB,MAAM;AACzB,QAAI,CAAC,kBAAkB;AACrB,SAAI,uBAAuB,CAAE;AAC7B,SAAI,2CACF,4BAA2B;AAE7B;;AAGF,QAAI,oBAAoB,UAAU,EAAE;AAClC,SAAI,uBAAuB,CAAE;AAC7B,SAAI,2CACF,4BAA2B;AAE7B;;AAGF,oBAAgB;AAChB,gBAAY,OAAO;;GAGrB,MAAM,iBAAiB,IAAI,gBAAgB,UAAU;AAErD,OAAI,MAAM,cAAc,eAAe,EAAE;AACvC,yBAAqB;AACrB;;AAGF,SAAM,eAAe,eAAe;AACpC,wBAAqB;;EAExB"}
1
+ {"version":3,"file":"reconciler.mjs","names":[],"sources":["../../../../src/core/collection/reconciler.ts"],"sourcesContent":["import type { CollectionStore } from \"./collection\";\nimport { collectionSearchEqual, mergeCollectionParams, normalizeCollectionSearch } from \"./url\";\n\n/**\n * Adapter-supplied hooks that let the reconciler read framework state and\n * push navigation side-effects without depending on any specific framework.\n */\nexport type ReconcilerCallbacks = {\n /** Returns the current collection store instance. */\n getStore: () => CollectionStore;\n /** Reads the live URL search string from the framework router. */\n readUrlSearch: () => string;\n /** Delivers a serialized search string for the framework adapter to handle. */\n emitChange: (searchString: string) => void;\n};\n\n/**\n * State machine that keeps the URL, server data, and `CollectionStore` in\n * sync during browse-change chains. Created by `createCollectionReconciler`.\n */\nexport type CollectionReconciler = {\n /**\n * URL ↔ store reconciliation state machine. Call whenever `urlSearch` or\n * `dataSearch` changes. Store changes go through\n * {@link CollectionReconciler.handleBrowseChange} instead.\n */\n reconcile(urlSearch: string, dataSearch: string): void;\n /**\n * Callback for {@link CollectionStore.setOnBrowseChange}. Merges current\n * URL params with store state, tracks the pending navigation, and emits.\n */\n handleBrowseChange(): void;\n /** Clears pending navigation state (call when the store is recreated). */\n reset(newPrevUrlSearch: string): void;\n};\n\n/**\n * Creates a framework-agnostic reconciler that manages the state machine for\n * keeping the URL, server data, and store in sync during browse-change chains.\n *\n * Framework adapters delegate to this so the reconciliation logic\n * lives in one place.\n */\nexport function createCollectionReconciler(\n callbacks: ReconcilerCallbacks,\n initialPrevUrlSearch = \"\",\n): CollectionReconciler {\n let pendingFilter: string | null = null;\n let pendingUrls = new Set<string>();\n let prevUrlSearch = initialPrevUrlSearch;\n\n return {\n handleBrowseChange() {\n const store = callbacks.getStore();\n const merged = mergeCollectionParams(\n new URLSearchParams(callbacks.readUrlSearch()),\n store.getState(),\n );\n const searchString = merged.toString();\n pendingFilter = searchString;\n pendingUrls.add(searchString);\n callbacks.emitChange(searchString ? `?${searchString}` : \"\");\n },\n\n reset(newPrevUrlSearch: string) {\n pendingFilter = null;\n pendingUrls = new Set();\n prevUrlSearch = newPrevUrlSearch;\n },\n\n // State machine — four states:\n //\n // ┌─────────────────────────────────────────────────────────────────────┐\n // │ State A: No pending navigation (pendingFilter === null) │\n // │ • URL matches store → try settle if server data caught up │\n // │ • URL differs from store → syncFromParams, then try settle │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State B: Pending — URL matches target (pendingTargetMatchesUrl) │\n // │ • Server data matches URL → settle, clear pending state │\n // │ • Server data stale (intermediate) → re-dispatch to target │\n // │ • Server data not yet arrived → wait (keep pending set) │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State C: Pending — URL is intermediate (in pendingUrls) │\n // │ • Ignore URL change; if router settled here with server data │\n // │ confirming the intermediate, re-dispatch to the real target │\n // ├─────────────────────────────────────────────────────────────────────┤\n // │ State D: Pending — URL is unknown (not in pendingUrls) │\n // │ • External navigation (back/forward, <Link>) │\n // │ • Abandon pending state, fall through to State A │\n // └─────────────────────────────────────────────────────────────────────┘\n //\n // Rapid toggles (A on → B on → A off) produce multiple in-flight navigations.\n // pendingFilter tracks the latest target; pendingUrls tracks every URL\n // dispatched in the chain so intermediates can be distinguished from external navs.\n reconcile(urlSearch: string, dataSearch: string) {\n const store = callbacks.getStore();\n\n const pendingSearchEquals = (search: string): boolean =>\n [...pendingUrls].some((pending) => collectionSearchEqual(pending, search));\n\n const pendingTargetMatchesUrl =\n pendingFilter != null && collectionSearchEqual(pendingFilter, urlSearch);\n\n const trySettleFromServer = (): boolean => {\n if (store.getState().status !== \"loading\") return false;\n if (!collectionSearchEqual(dataSearch, urlSearch)) return false;\n\n const incomingParams = new URLSearchParams(urlSearch);\n if (!store.matchesParams(incomingParams)) {\n store.syncFromParams(incomingParams);\n }\n\n store.settle();\n return true;\n };\n\n const clearPendingIfSettled = (): boolean => {\n if (!trySettleFromServer()) return false;\n pendingFilter = null;\n pendingUrls.clear();\n return true;\n };\n\n if (clearPendingIfSettled()) return;\n\n const normalizedUrlSearch = normalizeCollectionSearch(urlSearch);\n const normalizedPrevUrlSearch = normalizeCollectionSearch(prevUrlSearch);\n const urlSearchChanged = normalizedUrlSearch !== normalizedPrevUrlSearch;\n prevUrlSearch = urlSearch;\n\n const reDispatchToPendingTarget = () => {\n if (pendingFilter == null) return;\n callbacks.emitChange(pendingFilter ? `?${pendingFilter}` : \"\");\n };\n\n const routerSettledOnIntermediateWithStaleTarget =\n pendingFilter != null &&\n pendingSearchEquals(urlSearch) &&\n !pendingTargetMatchesUrl &&\n collectionSearchEqual(dataSearch, urlSearch);\n\n const serverConfirmedIntermediateWhileTargeting =\n pendingFilter != null &&\n pendingSearchEquals(dataSearch) &&\n !collectionSearchEqual(dataSearch, pendingFilter) &&\n !collectionSearchEqual(dataSearch, urlSearch);\n\n if (pendingTargetMatchesUrl) {\n if (trySettleFromServer()) {\n pendingFilter = null;\n pendingUrls.clear();\n } else if (\n store.getState().status === \"loading\" &&\n serverConfirmedIntermediateWhileTargeting\n ) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n if (pendingFilter != null) {\n if (!urlSearchChanged) {\n if (clearPendingIfSettled()) return;\n if (routerSettledOnIntermediateWithStaleTarget) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n if (pendingSearchEquals(urlSearch)) {\n if (clearPendingIfSettled()) return;\n if (routerSettledOnIntermediateWithStaleTarget) {\n reDispatchToPendingTarget();\n }\n return;\n }\n\n pendingFilter = null;\n pendingUrls.clear();\n }\n\n const incomingParams = new URLSearchParams(urlSearch);\n\n if (store.matchesParams(incomingParams)) {\n trySettleFromServer();\n return;\n }\n\n store.syncFromParams(incomingParams);\n trySettleFromServer();\n },\n };\n}\n"],"mappings":";;;;;;;;;;AA2CA,SAAgB,2BACd,WACA,uBAAuB,IACD;CACtB,IAAI,gBAA+B;CACnC,IAAI,8BAAc,IAAI,KAAa;CACnC,IAAI,gBAAgB;AAEpB,QAAO;EACL,qBAAqB;GACnB,MAAM,QAAQ,UAAU,UAAU;GAKlC,MAAM,eAJS,sBACb,IAAI,gBAAgB,UAAU,eAAe,CAAC,EAC9C,MAAM,UAAU,CAES,CAAC,UAAU;AACtC,mBAAgB;AAChB,eAAY,IAAI,aAAa;AAC7B,aAAU,WAAW,eAAe,IAAI,iBAAiB,GAAG;;EAG9D,MAAM,kBAA0B;AAC9B,mBAAgB;AAChB,iCAAc,IAAI,KAAK;AACvB,mBAAgB;;EA2BlB,UAAU,WAAmB,YAAoB;GAC/C,MAAM,QAAQ,UAAU,UAAU;GAElC,MAAM,uBAAuB,WAC3B,CAAC,GAAG,YAAY,CAAC,MAAM,YAAY,sBAAsB,SAAS,OAAO,CAAC;GAE5E,MAAM,0BACJ,iBAAiB,QAAQ,sBAAsB,eAAe,UAAU;GAE1E,MAAM,4BAAqC;AACzC,QAAI,MAAM,UAAU,CAAC,WAAW,UAAW,QAAO;AAClD,QAAI,CAAC,sBAAsB,YAAY,UAAU,CAAE,QAAO;IAE1D,MAAM,iBAAiB,IAAI,gBAAgB,UAAU;AACrD,QAAI,CAAC,MAAM,cAAc,eAAe,CACtC,OAAM,eAAe,eAAe;AAGtC,UAAM,QAAQ;AACd,WAAO;;GAGT,MAAM,8BAAuC;AAC3C,QAAI,CAAC,qBAAqB,CAAE,QAAO;AACnC,oBAAgB;AAChB,gBAAY,OAAO;AACnB,WAAO;;AAGT,OAAI,uBAAuB,CAAE;GAI7B,MAAM,mBAFsB,0BAA0B,UAEV,KADZ,0BAA0B,cACc;AACxE,mBAAgB;GAEhB,MAAM,kCAAkC;AACtC,QAAI,iBAAiB,KAAM;AAC3B,cAAU,WAAW,gBAAgB,IAAI,kBAAkB,GAAG;;GAGhE,MAAM,6CACJ,iBAAiB,QACjB,oBAAoB,UAAU,IAC9B,CAAC,2BACD,sBAAsB,YAAY,UAAU;GAE9C,MAAM,4CACJ,iBAAiB,QACjB,oBAAoB,WAAW,IAC/B,CAAC,sBAAsB,YAAY,cAAc,IACjD,CAAC,sBAAsB,YAAY,UAAU;AAE/C,OAAI,yBAAyB;AAC3B,QAAI,qBAAqB,EAAE;AACzB,qBAAgB;AAChB,iBAAY,OAAO;eAEnB,MAAM,UAAU,CAAC,WAAW,aAC5B,0CAEA,4BAA2B;AAE7B;;AAGF,OAAI,iBAAiB,MAAM;AACzB,QAAI,CAAC,kBAAkB;AACrB,SAAI,uBAAuB,CAAE;AAC7B,SAAI,2CACF,4BAA2B;AAE7B;;AAGF,QAAI,oBAAoB,UAAU,EAAE;AAClC,SAAI,uBAAuB,CAAE;AAC7B,SAAI,2CACF,4BAA2B;AAE7B;;AAGF,oBAAgB;AAChB,gBAAY,OAAO;;GAGrB,MAAM,iBAAiB,IAAI,gBAAgB,UAAU;AAErD,OAAI,MAAM,cAAc,eAAe,EAAE;AACvC,yBAAqB;AACrB;;AAGF,SAAM,eAAe,eAAe;AACpC,wBAAqB;;EAExB"}
@@ -4,10 +4,9 @@ import { ProductFilter } from "../../vendor/standard-events.mjs";
4
4
  //#region src/core/collection/state.d.ts
5
5
  /** How a filter option is visually presented in the storefront UI. */
6
6
  type FilterPresentation = "IMAGE" | "SWATCH" | "TEXT";
7
- /** The input mechanism a filter uses — boolean toggle, multi-select list, or price slider. */
7
+ /** The input mechanism a filter uses — e.g. boolean toggle, multi-select list, or price range. */
8
8
  type FilterType = "BOOLEAN" | "LIST" | "PRICE_RANGE" | (string & {});
9
9
  interface BaseAvailableFilterValue {
10
- /** Unique identifier for this filter value. */
11
10
  id: string;
12
11
  /** Human-readable label (e.g. "Red", "Nike", "$50–$100"). */
13
12
  label: string;
@@ -30,8 +29,14 @@ type AvailableFilterValue<TValue extends BaseAvailableFilterValue = BaseAvailabl
30
29
  */
31
30
  interface AvailableFilter<TValue extends BaseAvailableFilterValue = BaseAvailableFilterValue> {
32
31
  id: string;
32
+ /** Human-readable name (e.g. "Color", "Size", "Price"). */
33
33
  label: string;
34
+ /** Input mechanism — e.g. boolean toggle, multi-select list, or price range. */
34
35
  type: FilterType;
36
+ /**
37
+ * Visual presentation hint for the storefront UI. Populated only for `LIST`
38
+ * filters; `null` otherwise.
39
+ */
35
40
  presentation?: FilterPresentation | null;
36
41
  values: AvailableFilterValue<TValue>[];
37
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"state.mjs","names":[],"sources":["../../../../src/core/collection/state.ts"],"sourcesContent":["import type { ProductFilter } from \"../../../vendor/standard-events\";\nimport type { ProductCollectionSortKeys } from \"../../graphql/generated/storefront-api-types\";\n\nexport type { ProductCollectionSortKeys, ProductFilter };\n\n/** How a filter option is visually presented in the storefront UI. */\nexport type FilterPresentation = \"IMAGE\" | \"SWATCH\" | \"TEXT\";\n\n/** The input mechanism a filter uses — boolean toggle, multi-select list, or price slider. */\nexport type FilterType = \"BOOLEAN\" | \"LIST\" | \"PRICE_RANGE\" | (string & {});\n\ninterface BaseAvailableFilterValue {\n /** Unique identifier for this filter value. */\n id: string;\n /** Human-readable label (e.g. \"Red\", \"Nike\", \"$50–$100\"). */\n label: string;\n /** Number of products matching this value in the current result set. */\n count: number;\n /** JSON-encoded `ProductFilter` input to apply this value. */\n input: string;\n}\n\ntype PickIfPresent<TValue, TKey extends PropertyKey> = TKey extends keyof TValue\n ? Pick<TValue, TKey>\n : {};\n\n/**\n * A single selectable value within an {@link AvailableFilter}.\n *\n * Pass your Storefront API query value type as `TValue` to expose query-selected\n * visual fields such as `swatch`.\n */\nexport type AvailableFilterValue<\n TValue extends BaseAvailableFilterValue = BaseAvailableFilterValue,\n> = BaseAvailableFilterValue & PickIfPresent<TValue, \"swatch\">;\n\n/**\n * A filter facet available for the current collection, returned by the\n * Storefront API. Used to render filter UI (checkboxes, swatches, sliders).\n */\nexport interface AvailableFilter<\n TValue extends BaseAvailableFilterValue = BaseAvailableFilterValue,\n> {\n id: string;\n label: string;\n type: FilterType;\n presentation?: FilterPresentation | null;\n values: AvailableFilterValue<TValue>[];\n}\n\n/**\n * Pure browse-intent state for a collection store. Represents what the user\n * wants to see (filters, sort), not what the server returned.\n *\n * Server response data (productsCount, availableFilters, collection id) lives\n * in the framework's loader data, not in the store.\n */\nexport interface CollectionState {\n /** URL-safe slug identifying the collection (e.g. `\"shoes\"`). */\n handle: string;\n\n /** Active product filters, synced bidirectionally with URL params (`filter.*`). */\n filters: ProductFilter[];\n /** Storefront API sort key. `undefined` means the collection's default sort order. */\n sortKey: ProductCollectionSortKeys | undefined;\n /** When `true`, the sort direction is descending (maps to `sort_by` suffix `-descending`). */\n reverse: boolean;\n /** `\"idle\"` when browse state matches the last-loaded data, `\"loading\"` after a browse change before the framework fetch settles. */\n status: \"idle\" | \"loading\";\n}\n\n/**\n * Creates a blank {@link CollectionState} for the given collection handle.\n * All browse fields start at their zero values.\n *\n * @param handle - URL-safe collection slug (e.g. `\"shoes\"`)\n */\nexport function createInitialCollectionState(handle: string): CollectionState {\n return {\n handle,\n filters: [],\n sortKey: undefined,\n reverse: false,\n status: \"idle\",\n };\n}\n"],"mappings":";;;;;;;AA6EA,SAAgB,6BAA6B,QAAiC;AAC5E,QAAO;EACL;EACA,SAAS,EAAE;EACX,SAAS;EACT,SAAS;EACT,QAAQ;EACT"}
1
+ {"version":3,"file":"state.mjs","names":[],"sources":["../../../../src/core/collection/state.ts"],"sourcesContent":["import type { ProductFilter } from \"../../../vendor/standard-events\";\nimport type { ProductCollectionSortKeys } from \"../../graphql/generated/storefront-api-types\";\n\nexport type { ProductCollectionSortKeys, ProductFilter };\n\n/** How a filter option is visually presented in the storefront UI. */\nexport type FilterPresentation = \"IMAGE\" | \"SWATCH\" | \"TEXT\";\n\n/** The input mechanism a filter uses — e.g. boolean toggle, multi-select list, or price range. */\nexport type FilterType = \"BOOLEAN\" | \"LIST\" | \"PRICE_RANGE\" | (string & {});\n\ninterface BaseAvailableFilterValue {\n id: string;\n /** Human-readable label (e.g. \"Red\", \"Nike\", \"$50–$100\"). */\n label: string;\n /** Number of products matching this value in the current result set. */\n count: number;\n /** JSON-encoded `ProductFilter` input to apply this value. */\n input: string;\n}\n\ntype PickIfPresent<TValue, TKey extends PropertyKey> = TKey extends keyof TValue\n ? Pick<TValue, TKey>\n : {};\n\n/**\n * A single selectable value within an {@link AvailableFilter}.\n *\n * Pass your Storefront API query value type as `TValue` to expose query-selected\n * visual fields such as `swatch`.\n */\nexport type AvailableFilterValue<\n TValue extends BaseAvailableFilterValue = BaseAvailableFilterValue,\n> = BaseAvailableFilterValue & PickIfPresent<TValue, \"swatch\">;\n\n/**\n * A filter facet available for the current collection, returned by the\n * Storefront API. Used to render filter UI (checkboxes, swatches, sliders).\n */\nexport interface AvailableFilter<\n TValue extends BaseAvailableFilterValue = BaseAvailableFilterValue,\n> {\n id: string;\n /** Human-readable name (e.g. \"Color\", \"Size\", \"Price\"). */\n label: string;\n /** Input mechanism — e.g. boolean toggle, multi-select list, or price range. */\n type: FilterType;\n /**\n * Visual presentation hint for the storefront UI. Populated only for `LIST`\n * filters; `null` otherwise.\n */\n presentation?: FilterPresentation | null;\n values: AvailableFilterValue<TValue>[];\n}\n\n/**\n * Pure browse-intent state for a collection store. Represents what the user\n * wants to see (filters, sort), not what the server returned.\n *\n * Server response data (productsCount, availableFilters, collection id) lives\n * in the framework's loader data, not in the store.\n */\nexport interface CollectionState {\n /** URL-safe slug identifying the collection (e.g. `\"shoes\"`). */\n handle: string;\n\n /** Active product filters, synced bidirectionally with URL params (`filter.*`). */\n filters: ProductFilter[];\n /** Storefront API sort key. `undefined` means the collection's default sort order. */\n sortKey: ProductCollectionSortKeys | undefined;\n /** When `true`, the sort direction is descending (maps to `sort_by` suffix `-descending`). */\n reverse: boolean;\n /** `\"idle\"` when browse state matches the last-loaded data, `\"loading\"` after a browse change before the framework fetch settles. */\n status: \"idle\" | \"loading\";\n}\n\n/**\n * Creates a blank {@link CollectionState} for the given collection handle.\n * All browse fields start at their zero values.\n *\n * @param handle - URL-safe collection slug (e.g. `\"shoes\"`)\n */\nexport function createInitialCollectionState(handle: string): CollectionState {\n return {\n handle,\n filters: [],\n sortKey: undefined,\n reverse: false,\n status: \"idle\",\n };\n}\n"],"mappings":";;;;;;;AAkFA,SAAgB,6BAA6B,QAAiC;AAC5E,QAAO;EACL;EACA,SAAS,EAAE;EACX,SAAS;EACT,SAAS;EACT,QAAQ;EACT"}
@@ -8,8 +8,11 @@ import { CollectionState } from "./state.mjs";
8
8
  * that is derived from the URL query string.
9
9
  */
10
10
  interface CollectionParams {
11
+ /** Active product filters parsed from `filter.*` URL keys. */
11
12
  filters: ProductFilter[];
13
+ /** Storefront API sort key parsed from the `sort_by` param. `undefined` when absent. */
12
14
  sortKey: ProductCollectionSortKeys | undefined;
15
+ /** `true` when the `sort_by` value ends with `-descending`. */
13
16
  reverse: boolean;
14
17
  }
15
18
  /**