@wix/agent-skills 1.19.2 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
  3. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +7 -6
  4. package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
  5. package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
  6. package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
  7. package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
  8. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
  9. package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
  10. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
  11. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  12. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
  13. package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
  14. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
  15. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
  16. package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
  17. package/skills/wix-headless/references/CAPABILITIES.md +2 -2
  18. package/skills/wix-headless/references/inline-recipes/experience-store.md +4 -3
  19. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +98 -18
  20. package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
  21. package/skills/wix-headless-fast/SKILL.md +200 -34
  22. package/skills/wix-headless-fast/entry/skill.md +10 -2
  23. package/skills/wix-headless-fast/install/attach.mjs +253 -0
  24. package/skills/wix-headless-fast/install/deploy.mjs +109 -9
  25. package/skills/wix-headless-fast/install/fast-path.mjs +28 -31
  26. package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +224 -60
  27. package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
  28. package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
  29. package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
  30. package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
  31. package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
  32. package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
  33. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
  34. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
  35. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
  36. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
  37. package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
  38. package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
  39. package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
  40. package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
  41. package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +179 -33
  42. package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
  43. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
  44. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
  45. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
  46. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
  47. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
  48. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
  49. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
  50. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
  51. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
  52. package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
  53. package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
  54. package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
  55. package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +236 -62
  56. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
  57. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
  58. package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
  59. package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
  60. package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
  61. package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
  62. package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
  63. package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
  64. package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +239 -83
  65. package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
  66. package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
  67. package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
  68. package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
  69. package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
  70. package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
  71. package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
  72. package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
  73. package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
  74. package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
  75. package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
  76. package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
  77. package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
  78. package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
  79. package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +214 -26
  80. package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
  81. package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
  82. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
  83. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
  84. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
  85. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
  86. package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
  87. package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
  88. package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
  89. package/skills/wix-headless-fast/references/forms/seed/seed-forms.mjs +15 -8
  90. package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +314 -63
  91. package/skills/wix-headless-fast/references/members/app/components/members/LoginForm.tsx +1 -1
  92. package/skills/wix-headless-fast/references/members/app/wix/members/auth-core.ts +105 -0
  93. package/skills/wix-headless-fast/references/members/app/wix/members/auth.ts +36 -65
  94. package/skills/wix-headless-fast/references/members/app/wix/members/client.ts +1 -1
  95. package/skills/wix-headless-fast/references/members/app/wix/members/members-core.ts +43 -0
  96. package/skills/wix-headless-fast/references/members/app/wix/members/members.ts +11 -32
  97. package/skills/wix-headless-fast/references/members/rest/auth.ts +215 -0
  98. package/skills/wix-headless-fast/references/members/rest/members.ts +26 -0
  99. package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +238 -59
  100. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
  101. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
  102. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
  103. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
  104. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
  105. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
  106. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
  107. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
  108. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
  109. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
  110. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
  111. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
  112. package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
  113. package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
  114. package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +212 -51
  115. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
  116. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
  117. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
  118. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
  119. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
  120. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
  121. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
  122. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
  123. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
  124. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
  125. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
  126. package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
  127. package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
  128. package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
  129. package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +240 -47
  130. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
  131. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
  132. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
  133. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
  134. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
  135. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
  136. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
  137. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
  138. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
  139. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
  140. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
  141. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
  142. package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
  143. package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
  144. package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
  145. package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
  146. package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
  147. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +55 -1
  148. package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
  149. package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
  150. package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
  151. package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
  152. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +371 -81
  153. package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
  154. package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
  155. package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx +95 -0
  156. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +136 -0
  157. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -130
  158. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -182
  159. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
  160. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +53 -136
  161. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +309 -0
  162. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +73 -230
  163. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
  164. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
  165. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
  166. package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
  167. package/skills/wix-headless-fast/references/storefront/rest/cart.ts +132 -0
  168. package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
  169. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +25 -5
  170. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +24 -0
  171. package/skills/wix-manage/SKILL.md +4 -1
  172. package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
  173. package/skills/wix-manage/references/events/create-wix-event.md +46 -0
  174. package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
  175. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
  176. package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
  177. package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
  178. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
  179. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
  180. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  181. package/skills/wix-vibe-headless/platforms/base44.md +19 -4
  182. package/skills/wix-vibe-headless/platforms/generic.md +6 -0
  183. package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
  184. package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
  185. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
  186. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
  187. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
  188. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
  189. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
  190. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
  191. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
  192. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
  193. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
  194. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
  195. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
  196. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
  197. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
  198. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
  199. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
  200. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
  201. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
  202. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
  203. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
  204. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
  205. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
  206. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
  207. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
  208. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
  209. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
  210. package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
  211. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +42 -41
  212. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +7 -1
  213. package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
  214. package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
  215. package/skills/wix-headless-fast/install/pin-agents-md.mjs +0 -57
@@ -1,7 +1,8 @@
1
- // Cart + checkout (Wix eCom Cart V2) — the only file that touches raw cart entities.
2
- // Copy as-is; extend by calling these exports, never by editing them. The request shapes are
3
- // exact (catalogItems wrapper, options.variantId, the redirect-session body) and rewriting
4
- // them is how carts break.
1
+ // Cart + checkout (Wix eCom Cart V2) over the SDK — the only file that touches raw cart entities
2
+ // on this transport. Rules and mappers live in ./cart-core (shared with the REST twin in
3
+ // references/storefront/rest/); this file is the transport only. Copy as-is; extend by calling
4
+ // these exports, never by editing them. The request shapes are exact (catalogItems wrapper,
5
+ // options.variantId, the redirect-session body) and rewriting them is how carts break.
5
6
  //
6
7
  // Failures are loud: these throw on out-of-stock lines, an empty cart at checkout, and a
7
8
  // missing required selection — surface the message to the buyer, don't swallow it.
@@ -9,106 +10,74 @@
9
10
  // docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/add-line-items-to-current-cart.md
10
11
  // docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/update-line-items.md
11
12
  // docs: https://dev.wix.com/docs/api-reference/business-management/headless/redirects/create-redirect-session.md
12
- // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
13
13
  import { currentCartV2 } from "@wix/ecom";
14
14
  import { redirects as redirectsModule } from "@wix/redirects";
15
15
  import { productsV3 } from "@wix/stores";
16
16
  import { wixModule } from "../sdk";
17
17
  import { imgSrc } from "../media";
18
- import { formatMoney } from "../money";
19
- import type { Cart, CartLine } from "./types";
18
+ import { WIX_STORES_APP_ID, addOptions, assertAdded, assertCheckoutable, rawId, summaryTotals, toCart } from "./cart-core";
19
+ import type { Raw } from "./catalog-core";
20
+ import type { Cart } from "./types";
20
21
 
21
22
  const cartApi = wixModule(currentCartV2);
22
23
  const redirects = wixModule(redirectsModule);
23
24
  const productsApi = wixModule(productsV3);
24
25
 
26
+ export interface AddToCartExtras {
27
+ /** TEXT_CHOICES modifier selections: modifier key -> choice key (options.options — MODIFIERS only, never Size/Color). */
28
+ modifierChoices?: Record<string, string>;
29
+ /** FREE_TEXT modifier inputs: freeTextSettings key -> the buyer's text. */
30
+ customTextFields?: Record<string, string>;
31
+ /** The variant is out of stock but pre-orderable — sends preOrderRequested. */
32
+ preorder?: boolean;
33
+ /** A chosen recurring plan (subscriptionPricesInfo); omit for a one-time purchase. */
34
+ subscriptionOptionId?: string;
35
+ }
36
+
25
37
  // The V2 cart does NOT return line-item images (attributes.image is typed but comes back
26
38
  // absent — verified against a live cart), so images are joined from the catalog by the
27
39
  // line's catalogItemId and cached for the session.
28
40
  const lineImageCache = new Map<string, string>();
29
-
30
- async function fillLineImages(lines: CartLine[], raws: RawCart[]): Promise<void> {
41
+ async function fillLineImages(cart: Cart, raws: Raw[]): Promise<void> {
31
42
  const wanted = new Map<string, number[]>(); // productId -> line indexes
32
43
  raws.forEach((raw, i) => {
33
- if (lines[i].imageUrl) return;
44
+ if (cart.lines[i].imageUrl) return;
34
45
  const pid = raw.source?.catalogReference?.catalogItemId;
35
46
  if (!pid) return;
36
47
  const cached = lineImageCache.get(pid);
37
48
  if (cached !== undefined) {
38
- lines[i].imageUrl = cached;
49
+ cart.lines[i].imageUrl = cached;
39
50
  return;
40
51
  }
41
52
  wanted.set(pid, [...(wanted.get(pid) ?? []), i]);
42
53
  });
43
54
  if (!wanted.size) return;
44
55
  try {
45
- const res = await productsApi
46
- .queryProducts()
47
- .in("_id", [...wanted.keys()])
48
- .find();
49
- for (const p of (res.items ?? []) as RawCart[]) {
56
+ const res = await productsApi.queryProducts().in("_id", [...wanted.keys()]).find();
57
+ for (const p of (res.items ?? []) as Raw[]) {
50
58
  const url = imgSrc(p.media?.main, 300, 300);
51
- lineImageCache.set(p._id, url);
52
- for (const i of wanted.get(p._id) ?? []) lines[i].imageUrl = url;
59
+ lineImageCache.set(rawId(p), url);
60
+ for (const i of wanted.get(rawId(p)) ?? []) cart.lines[i].imageUrl = url;
53
61
  }
54
62
  } catch {
55
63
  /* images are a nicety — the cart stays correct without them */
56
64
  }
57
65
  }
58
66
 
59
- // Public app id of the Wix Stores catalog — required inside every catalogReference.
60
- const WIX_STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
61
-
62
- type RawCart = Record<string, any>;
63
-
64
- function cartCurrency(raw: RawCart | null): string {
65
- return raw?.customerInfo?.currencyCode ?? raw?.businessInfo?.currencyCode ?? "";
66
- }
67
-
68
- function toLine(raw: RawCart, currency: string): CartLine {
69
- const descriptionLines: string[] = (raw.attributes?.descriptionLines ?? [])
70
- .map((d: RawCart) => {
71
- const label = d.name?.original ?? "";
72
- const value = d.plainText?.original ?? d.colorInfo?.original ?? "";
73
- return label && value ? `${label}: ${value}` : value || label;
74
- })
75
- .filter(Boolean);
76
- return {
77
- lineItemId: raw._id ?? "",
78
- productName: raw.name?.original ?? "",
79
- quantity: raw.quantityInfo?.confirmedQuantity ?? 0,
80
- unitPrice: formatMoney(raw.pricing?.unitPrice, currency),
81
- linePrice: formatMoney(raw.pricing?.totalPrice, currency),
82
- imageUrl: imgSrc(raw.attributes?.image, 300, 300),
83
- descriptionLines,
84
- status: raw.status ?? "IN_STOCK",
85
- };
86
- }
87
-
88
- function toCart(raw: RawCart | null, subtotal: string): Cart {
89
- const currency = cartCurrency(raw);
90
- const lines = (raw?.lineItems ?? []).map((l: RawCart) => toLine(l, currency));
91
- return {
92
- lines,
93
- itemCount: lines.reduce((sum: number, l: CartLine) => sum + l.quantity, 0),
94
- subtotal,
95
- currency,
96
- };
97
- }
98
-
99
- async function readCartWithSubtotal(raw: RawCart | null): Promise<Cart> {
100
- if (!raw?.lineItems?.length) return toCart(raw, "");
101
- // The authoritative after-discount subtotal comes from the cart estimate, never from
102
- // hand-summing line items (tax and shipping resolve at checkout).
103
- let subtotal = "";
67
+ async function readCartWithSubtotal(raw: Raw | null): Promise<Cart> {
68
+ // Only estimate a cart WITH lines — on an absent/empty cart the endpoint returns 404.
69
+ if (!raw?.lineItems?.length) return toCart(raw, "", "", imgSrc);
70
+ // The authoritative after-discount subtotal and the CART-level discount come from the cart
71
+ // estimate, never from hand-summing lines. Its delivery/tax/fees read "0" when nothing was
72
+ // calculated — never render those as "Free"; shipping and tax resolve at checkout.
73
+ let totals = { subtotal: "", discount: "" };
104
74
  try {
105
- const estimate: RawCart = await cartApi.estimateCurrentCart();
106
- subtotal = formatMoney(estimate?.summary?.priceSummary?.subtotal, cartCurrency(raw));
75
+ totals = summaryTotals(await cartApi.estimateCurrentCart(), raw);
107
76
  } catch {
108
77
  /* estimate is a display nicety — the cart itself is still valid */
109
78
  }
110
- const cart = toCart(raw, subtotal);
111
- await fillLineImages(cart.lines, raw.lineItems as RawCart[]);
79
+ const cart = toCart(raw, totals.subtotal, totals.discount, imgSrc);
80
+ await fillLineImages(cart, raw.lineItems as Raw[]);
112
81
  return cart;
113
82
  }
114
83
 
@@ -116,77 +85,36 @@ async function readCartWithSubtotal(raw: RawCart | null): Promise<Cart> {
116
85
  export async function fetchCart(): Promise<Cart> {
117
86
  try {
118
87
  const { cart } = await cartApi.getCurrentCart();
119
- return readCartWithSubtotal(cart as RawCart);
88
+ return readCartWithSubtotal(cart as Raw);
120
89
  } catch {
121
- return toCart(null, "");
90
+ return toCart(null, "", "", imgSrc);
122
91
  }
123
92
  }
124
93
 
125
- export interface AddToCartExtras {
126
- /** TEXT_CHOICES modifier selections: modifier key -> choice key. */
127
- modifierChoices?: Record<string, string>;
128
- /** FREE_TEXT modifier inputs: freeTextSettings key -> the buyer's text. */
129
- customTextFields?: Record<string, string>;
130
- }
131
-
132
94
  /**
133
- * Add a product to the current cart.
134
- * For a product WITH options, `variantId` is mandatory — resolve it with
135
- * `resolveVariant()` from ./catalog first. Mandatory modifiers must be included.
95
+ * Add a product to the current cart. For a product WITH options, `variantId` is mandatory —
96
+ * resolve it with `resolveVariant()` from ./catalog first. Mandatory modifiers must be included.
97
+ * A refused add still returns 200, so the returned line is checked.
136
98
  */
137
- export async function addToCart(
138
- productId: string,
139
- variantId?: string | null,
140
- quantity = 1,
141
- { modifierChoices, customTextFields }: AddToCartExtras = {},
142
- ): Promise<Cart> {
143
- const options: Record<string, unknown> = {};
144
- if (variantId) options.variantId = variantId;
145
- if (modifierChoices && Object.keys(modifierChoices).length) options.options = modifierChoices;
146
- if (customTextFields && Object.keys(customTextFields).length) options.customTextFields = customTextFields;
147
-
99
+ export async function addToCart(productId: string, variantId?: string | null, quantity = 1, extras: AddToCartExtras = {}): Promise<Cart> {
100
+ const options = addOptions({ variantId, ...extras });
148
101
  const { cart } = await cartApi.addLineItemsToCurrentCart({
149
- catalogItems: [
150
- {
151
- quantity,
152
- catalogReference: {
153
- catalogItemId: productId,
154
- appId: WIX_STORES_APP_ID,
155
- ...(Object.keys(options).length ? { options } : {}),
156
- },
157
- },
158
- ],
102
+ catalogItems: [{ quantity, catalogReference: { catalogItemId: productId, appId: WIX_STORES_APP_ID, ...(Object.keys(options).length ? { options } : {}) } }],
159
103
  });
160
-
161
- const line = ((cart as RawCart)?.lineItems ?? []).find(
162
- (l: RawCart) =>
163
- l.source?.catalogReference?.catalogItemId === productId &&
164
- (!variantId || l.source?.catalogReference?.options?.variantId === variantId),
165
- );
166
- if (line?.status && line.status !== "IN_STOCK") {
167
- throw new Error(`This item isn't available right now (${line.status.toLowerCase().replace(/_/g, " ")}).`);
168
- }
169
- if (!line || line.quantityInfo?.confirmedQuantity === 0) {
170
- throw new Error(
171
- "The item couldn't be added. Make sure every required selection was made " +
172
- "(options for a product with variants, and all mandatory customizations).",
173
- );
174
- }
175
- return readCartWithSubtotal(cart as RawCart);
104
+ assertAdded(cart as Raw, productId, variantId);
105
+ return readCartWithSubtotal(cart as Raw);
176
106
  }
177
107
 
178
108
  /** Change a line's quantity. `lineItemId` is CartLine.lineItemId, never the product id. */
179
109
  export async function updateQuantity(lineItemId: string, quantity: number): Promise<Cart> {
180
- const { cart } = await cartApi.updateLineItemsInCurrentCart({
181
- lineItems: [{ lineItemId, quantity: { newQuantity: quantity } }],
182
- });
183
- return readCartWithSubtotal(cart as RawCart);
110
+ const { cart } = await cartApi.updateLineItemsInCurrentCart({ lineItems: [{ lineItemId, quantity: { newQuantity: quantity } }] });
111
+ return readCartWithSubtotal(cart as Raw);
184
112
  }
185
113
 
186
114
  /** Remove a line from the cart by CartLine.lineItemId. */
187
115
  export async function removeLine(lineItemId: string): Promise<Cart> {
188
116
  const { cart } = await cartApi.removeLineItemsFromCurrentCart([lineItemId]);
189
- return readCartWithSubtotal(cart as RawCart);
117
+ return readCartWithSubtotal(cart as Raw);
190
118
  }
191
119
 
192
120
  /**
@@ -197,23 +125,12 @@ export async function removeLine(lineItemId: string): Promise<Cart> {
197
125
  */
198
126
  export async function checkoutUrl(): Promise<string> {
199
127
  const { cart } = await cartApi.getCurrentCart();
200
- const raw = cart as RawCart;
201
- const lines: RawCart[] = raw?.lineItems ?? [];
202
- if (!lines.length) throw new Error("Your cart is empty.");
203
- const unavailable = lines.filter((l) => l.status && l.status !== "IN_STOCK");
204
- if (unavailable.length) {
205
- const names = unavailable.map((l) => l.name?.original).filter(Boolean).join(", ");
206
- throw new Error(`Some items are no longer available: ${names}.`);
207
- }
208
- if (!raw?._id) throw new Error("Checkout couldn't start: the cart has no id.");
209
-
128
+ const raw = cart as Raw;
129
+ assertCheckoutable(raw);
210
130
  const origin = typeof window !== "undefined" ? window.location.origin : "";
211
131
  const session = await redirects.createRedirectSession({
212
- ecomCheckout: { checkoutId: raw._id },
213
- callbacks: {
214
- postFlowUrl: origin ? `${origin}/` : undefined,
215
- thankYouPageUrl: origin ? `${origin}/` : undefined,
216
- },
132
+ ecomCheckout: { checkoutId: rawId(raw) },
133
+ callbacks: { postFlowUrl: origin ? `${origin}/` : undefined, thankYouPageUrl: origin ? `${origin}/` : undefined },
217
134
  });
218
135
  const url = session?.redirectSession?.fullUrl;
219
136
  if (!url) throw new Error("Checkout couldn't start: no redirect URL returned.");
@@ -0,0 +1,309 @@
1
+ // Catalog rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./catalog.ts
2
+ // (the SDK, managed Astro and React) and the REST twin in references/storefront/rest/catalog.ts
3
+ // (fetch, a static site or a port to another language). Every rule about prices, ribbons,
4
+ // ranges, media, variants lives HERE, once. A raw entity may come from the SDK (`_id`) or from
5
+ // REST (`id`); the mappers accept both. Imports are type-only so a strip to JS emits no imports.
6
+ import type {
7
+ Availability,
8
+ Category,
9
+ Facet,
10
+ FacetData,
11
+ ProductDetail,
12
+ ProductModifier,
13
+ ProductOption,
14
+ ProductSummary,
15
+ ProductVariant,
16
+ } from "./types";
17
+
18
+ /** A raw Catalog V3 entity as either transport returns it. */
19
+ export type Raw = Record<string, any>;
20
+ /** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
21
+ export type ImgSrc = (value: any, width: number, height: number) => string;
22
+ /** Media identity before scaling — de-duplicate on this, never on a resolved URL. */
23
+ export type MediaKey = (value: any) => string;
24
+
25
+ const id = (raw: Raw | undefined): string => raw?._id ?? raw?.id ?? "";
26
+
27
+ // Requested on every product read. CURRENCY → formattedAmount (without it prices render as bare
28
+ // numbers); MEDIA_ITEMS_INFO → the gallery; MIN_PRICE_VARIANT + DISCOUNT_INFO → the cheapest variant
29
+ // with its discounted price: a card's real price and the direct-add variant id, no per-product fetch.
30
+ export const LIST_FIELDS = ["CURRENCY", "MEDIA_ITEMS_INFO", "MIN_PRICE_VARIANT", "DISCOUNT_INFO"] as const;
31
+ // The detail read adds the HTML description, the variants (VARIANT_OPTION_CHOICE_NAMES — without it
32
+ // variantsInfo is null), and the info sections with HTML bodies.
33
+ export const DETAIL_FIELDS = [
34
+ ...LIST_FIELDS,
35
+ "PLAIN_DESCRIPTION",
36
+ "VARIANT_OPTION_CHOICE_NAMES",
37
+ "INFO_SECTION",
38
+ "INFO_SECTION_PLAIN_DESCRIPTION",
39
+ ] as const;
40
+
41
+ // ---- sort + filter ---------------------------------------------------------------------------------
42
+
43
+ export const CATALOG_SORTS = {
44
+ featured: { label: "Default order" }, // the catalog's own order — no sort sent, and NOT a sales ranking
45
+ priceAsc: { label: "Price: low to high" },
46
+ priceHigh: { label: "Price: high to low" },
47
+ name: { label: "Name: A–Z" },
48
+ newest: { label: "Newest" },
49
+ } as const;
50
+ export type CatalogSort = keyof typeof CATALOG_SORTS;
51
+
52
+ export const SORT_FIELDS: Partial<Record<CatalogSort, { fieldName: string; order: "ASC" | "DESC" }[]>> = {
53
+ priceAsc: [{ fieldName: "actualPriceRange.minValue.amount", order: "ASC" }, { fieldName: "name", order: "ASC" }],
54
+ priceHigh: [{ fieldName: "actualPriceRange.minValue.amount", order: "DESC" }, { fieldName: "name", order: "ASC" }],
55
+ name: [{ fieldName: "name", order: "ASC" }],
56
+ newest: [{ fieldName: "createdDate", order: "DESC" }, { fieldName: "name", order: "ASC" }],
57
+ };
58
+
59
+ export interface CatalogSearchOptions {
60
+ /** 1–100, default 24. */
61
+ limit?: number;
62
+ /** Continues the ORIGINAL query — send it alone; any change of selection starts over without one. */
63
+ cursor?: string | null;
64
+ categoryId?: string | null;
65
+ sort?: CatalogSort;
66
+ /** Inclusive bounds on the product's minimum actual variant price, site currency. */
67
+ minPrice?: number | string;
68
+ maxPrice?: number | string;
69
+ /** IN_STOCK only — excludes partially stocked and preorder-only products. */
70
+ inStockOnly?: boolean;
71
+ /** Name search, max 100 chars. */
72
+ search?: string;
73
+ /** Option-choice facets: products carrying ANY of these choice ids (see fetchFacetData). */
74
+ choiceIds?: string[];
75
+ }
76
+
77
+ export function priceBound(value: number | string | undefined, name: string): number | undefined {
78
+ if (value == null || value === "") return undefined;
79
+ const n = Number(value);
80
+ if (!Number.isFinite(n) || n < 0) throw new Error(`${name} must be a non-negative number.`);
81
+ return n;
82
+ }
83
+
84
+ /**
85
+ * The filter every catalog read shares (the page, its count, the facets) — one place, so they
86
+ * can't drift. Search rejects two operators in ONE field object: price bounds are separate
87
+ * conditions joined by $and. Category: `allCategoriesInfo.categories` with `$matchItems` on the
88
+ * id (includes parent categories); never `$hasSome`, never V1 `collectionIds`. Facets discover
89
+ * PRODUCTS carrying a choice; the picker / PDP still resolves the variant.
90
+ */
91
+ export function catalogFilter({ categoryId, minPrice, maxPrice, inStockOnly = false, choiceIds = [] }: CatalogSearchOptions = {}): Raw {
92
+ const min = priceBound(minPrice, "minPrice"), max = priceBound(maxPrice, "maxPrice");
93
+ if (min !== undefined && max !== undefined && min > max) throw new Error("minPrice must not exceed maxPrice.");
94
+ const and: Raw[] = [{ visible: true }];
95
+ if (categoryId) and.push({ "allCategoriesInfo.categories": { $matchItems: [{ id: categoryId }] } });
96
+ if (min !== undefined) and.push({ "actualPriceRange.minValue.amount": { $gte: String(min) } });
97
+ if (max !== undefined) and.push({ "actualPriceRange.minValue.amount": { $lte: String(max) } });
98
+ if (inStockOnly) and.push({ "inventory.availabilityStatus": { $eq: "IN_STOCK" } });
99
+ if (choiceIds.length) and.push({ "options.choicesSettings.choices.choiceId": { $hasSome: choiceIds } });
100
+ return { $and: and };
101
+ }
102
+
103
+ /** The full Search Products body (minus fields) for a page; a cursor request carries ONLY cursorPaging. */
104
+ export function searchQuery(o: CatalogSearchOptions): Raw {
105
+ const { limit = 24, cursor, sort = "featured", search = "" } = o;
106
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100.");
107
+ const paging = { cursorPaging: { limit, ...(cursor ? { cursor } : {}) } };
108
+ if (cursor) return paging;
109
+ if (!(sort in CATALOG_SORTS)) throw new Error("Unsupported catalog sort.");
110
+ if (search.trim().length > 100) throw new Error("Search must be at most 100 characters.");
111
+ return {
112
+ ...paging,
113
+ filter: catalogFilter(o),
114
+ ...(SORT_FIELDS[sort] ? { sort: SORT_FIELDS[sort] } : {}),
115
+ ...(search.trim() ? { search: { expression: search.trim(), fields: ["name"] } } : {}),
116
+ };
117
+ }
118
+
119
+ // ---- price rules ----------------------------------------------------------------------------------
120
+
121
+ /**
122
+ * The price the buyer pays and the price to strike — exact precedence, shared by cards and
123
+ * variants. An automatic discount (priceAfterDiscount) wins and strikes the regular actualPrice;
124
+ * else actualPrice with the merchant's compareAtPrice as the "was". `!== undefined` on purpose:
125
+ * a discounted price of 0 is a real price.
126
+ */
127
+ export function sellingPrice(price: Raw | undefined): { current?: Raw; original?: Raw } {
128
+ if (price?.priceAfterDiscount !== undefined) return { current: price.priceAfterDiscount, original: price.actualPrice };
129
+ return { current: price?.actualPrice, original: price?.compareAtPrice };
130
+ }
131
+
132
+ /** The struck price only when it is real and higher than what the buyer pays. */
133
+ export function strike(original: Raw | undefined, current: Raw | undefined): string | null {
134
+ const o = Number(original?.amount), c = Number(current?.amount);
135
+ return original?.formattedAmount && Number.isFinite(o) && Number.isFinite(c) && o > c ? original.formattedAmount : null;
136
+ }
137
+
138
+ /** Every merchant ribbon, primary first, de-duplicated. A ribbon is a label, never proof of a price. */
139
+ export function ribbonsOf(raw: Raw): string[] {
140
+ return [raw.ribbon?.name, ...((raw.additionalRibbons ?? []) as Raw[]).map((r) => r?.name)]
141
+ .filter((n, i, all): n is string => typeof n === "string" && n.length > 0 && all.indexOf(n) === i);
142
+ }
143
+
144
+ export function toAvailability(raw: Raw): Availability {
145
+ const s = raw.inventory?.availabilityStatus;
146
+ return s === "OUT_OF_STOCK" || s === "PARTIALLY_OUT_OF_STOCK" ? s : "IN_STOCK";
147
+ }
148
+
149
+ /**
150
+ * Every distinct media entry, main first, keyed on identity — two scaled URLs of one photo are
151
+ * ONE entry (the "two thumbnails for the same image" bug). Shared by the tile and the PDP.
152
+ */
153
+ export function mediaEntries(raw: Raw, mediaKey: MediaKey): unknown[] {
154
+ const out: unknown[] = [];
155
+ const seen = new Set<string>();
156
+ for (const m of [raw.media?.main, ...((raw.media?.itemsInfo?.items ?? []) as Raw[])]) {
157
+ const v = m?.image ?? m;
158
+ const k = mediaKey(v);
159
+ if (!k || seen.has(k)) continue;
160
+ seen.add(k);
161
+ out.push(v);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // ---- DTO mappers -----------------------------------------------------------------------------------
167
+
168
+ export function toSummary(raw: Raw, imgSrc: ImgSrc, mediaKey: MediaKey): ProductSummary {
169
+ const options: Raw[] = raw.options ?? [];
170
+ const media = mediaEntries(raw, mediaKey);
171
+ const mainUrl = imgSrc(media[0], 800, 800);
172
+ const hover = media.slice(1).map((m) => imgSrc(m, 800, 800)).filter(Boolean);
173
+ const availability = toAvailability(raw);
174
+ // Price: the cheapest variant's selling price (discount applied); the product RANGE when variants
175
+ // are priced differently — and NO struck price beside a range (a lone struck minimum claims a
176
+ // saving that may not apply to the variant picked).
177
+ const min = raw.actualPriceRange?.minValue, max = raw.actualPriceRange?.maxValue;
178
+ const isRange = !!(min?.amount && max?.amount && min.amount !== max.amount);
179
+ const minVariant: Raw | undefined = raw.variantSummary?.minPriceVariant;
180
+ const { current, original } = sellingPrice(minVariant?.price);
181
+ const ribbons = ribbonsOf(raw);
182
+ return {
183
+ id: id(raw),
184
+ slug: raw.slug ?? "",
185
+ name: raw.name ?? "",
186
+ price: current?.formattedAmount ?? min?.formattedAmount ?? "",
187
+ maxPrice: max?.formattedAmount ?? "",
188
+ compareAtPrice: isRange ? null : strike(original ?? raw.compareAtPriceRange?.minValue, current ?? min),
189
+ ribbon: ribbons[0] ?? null,
190
+ ribbons,
191
+ minPriceVariantId: minVariant ? id(minVariant) || minVariant.variantId || null : null,
192
+ availability,
193
+ // Out of stock but pre-orderable — label "Pre-order", not "Sold out".
194
+ preorder: raw.inventory?.preorderStatus === "ENABLED" && availability === "OUT_OF_STOCK",
195
+ imageUrl: mainUrl,
196
+ hoverImageUrl: hover[0] ?? "",
197
+ optionsSummary: options
198
+ .map((o) => {
199
+ const n = ((o.choicesSettings?.choices ?? []) as Raw[]).filter((c) => c.visible !== false).length;
200
+ return `${n} ${String(o.name ?? "").toLowerCase()}${n === 1 ? "" : "s"}`;
201
+ })
202
+ .join(" · "),
203
+ swatches: options
204
+ .filter((o) => o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES")
205
+ .flatMap((o) => (o.choicesSettings?.choices ?? []) as Raw[])
206
+ .filter((c) => c.visible !== false && c.colorCode)
207
+ .map((c) => String(c.colorCode)),
208
+ // Addable in one click: no options and plainly in stock.
209
+ quickAddable: options.length === 0 && availability === "IN_STOCK",
210
+ };
211
+ }
212
+
213
+ export function toOptions(raw: Raw): ProductOption[] {
214
+ return ((raw.options ?? []) as Raw[]).map((o) => ({
215
+ id: id(o) || o.name || "",
216
+ name: o.name ?? "",
217
+ isColor: o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES",
218
+ choices: ((o.choicesSettings?.choices ?? []) as Raw[])
219
+ .filter((c) => c.visible !== false) // a retired choice the merchant no longer sells
220
+ .map((c) => ({ choiceId: c.choiceId ?? "", name: c.name ?? "", colorCode: c.colorCode ?? null, inStock: c.inStock !== false })),
221
+ }));
222
+ }
223
+
224
+ export function toModifiers(raw: Raw): ProductModifier[] {
225
+ return ((raw.modifiers ?? []) as Raw[]).map((m) => ({
226
+ key: m.key ?? m.freeTextSettings?.key ?? m.name ?? "",
227
+ name: m.name ?? "",
228
+ mandatory: m.mandatory === true,
229
+ type: m.modifierRenderType === "FREE_TEXT" ? ("text" as const) : ("choices" as const),
230
+ choices: ((m.choicesSettings?.choices ?? []) as Raw[]).map((c) => ({ key: c.key ?? c.name ?? "", name: c.name ?? "" })),
231
+ }));
232
+ }
233
+
234
+ /** Variants keyed by optionName → choiceName. Buyable when in stock OR pre-orderable. */
235
+ export function toVariants(raw: Raw): ProductVariant[] {
236
+ return ((raw.variantsInfo?.variants ?? []) as Raw[])
237
+ .filter((v) => v.visible !== false)
238
+ .map((v) => {
239
+ const choices: Record<string, string> = {};
240
+ for (const c of (v.choices ?? []) as Raw[]) {
241
+ const on = c.optionChoiceNames?.optionName, cn = c.optionChoiceNames?.choiceName;
242
+ if (on && cn) choices[on] = cn;
243
+ }
244
+ const { current, original } = sellingPrice(v.price);
245
+ return {
246
+ variantId: id(v),
247
+ choices,
248
+ price: current?.formattedAmount ?? "",
249
+ compareAtPrice: strike(original, current),
250
+ inStock: v.inventoryStatus?.inStock !== false,
251
+ preorderEnabled: v.inventoryStatus?.preorderEnabled === true,
252
+ };
253
+ });
254
+ }
255
+
256
+ export function toDetail(raw: Raw, imgSrc: ImgSrc, mediaKey: MediaKey): ProductDetail {
257
+ return {
258
+ ...toSummary(raw, imgSrc, mediaKey),
259
+ descriptionHtml: raw.plainDescription ?? "", // an HTML string despite the name — render as HTML
260
+ infoSections: ((raw.infoSections ?? []) as Raw[])
261
+ .map((s) => ({ title: s.title ?? "", html: s.plainDescription ?? "" }))
262
+ .filter((s) => s.title || s.html),
263
+ // De-duplicated on media identity, then resolved once at one size.
264
+ gallery: mediaEntries(raw, mediaKey).map((m) => imgSrc(m, 1200, 1200)).filter(Boolean),
265
+ options: toOptions(raw),
266
+ modifiers: toModifiers(raw),
267
+ variants: toVariants(raw),
268
+ };
269
+ }
270
+
271
+ export function toCategory(raw: Raw): Category {
272
+ return { id: id(raw), slug: raw.slug ?? "", name: raw.name ?? "", description: raw.description ?? "" };
273
+ }
274
+
275
+ /** Facets + price bounds from a scope's products (the 100-product read), so the panel only offers filters that exist. */
276
+ export function toFacetData(products: Raw[]): FacetData {
277
+ const byName = new Map<string, Facet>();
278
+ let lo = Infinity, hi = -Infinity, currency = "";
279
+ for (const p of products) {
280
+ const min = Number(p.actualPriceRange?.minValue?.amount), max = Number(p.actualPriceRange?.maxValue?.amount);
281
+ if (Number.isFinite(min)) lo = Math.min(lo, min);
282
+ if (Number.isFinite(max)) hi = Math.max(hi, max);
283
+ currency = currency || p.currency || "";
284
+ for (const o of (p.options ?? []) as Raw[]) {
285
+ if (!o.name) continue;
286
+ const facet: Facet = byName.get(o.name) ?? { name: o.name, isColor: o.optionRenderType === "SWATCH_CHOICES" || o.optionRenderType === "COLOR_CHOICES", choices: [] };
287
+ for (const c of (o.choicesSettings?.choices ?? []) as Raw[]) {
288
+ if (c.visible === false || !c.choiceId) continue;
289
+ if (!facet.choices.some((x) => x.id === c.choiceId)) facet.choices.push({ id: c.choiceId, name: c.name ?? "", colorCode: c.colorCode ?? null });
290
+ }
291
+ byName.set(o.name, facet);
292
+ }
293
+ }
294
+ return {
295
+ facets: [...byName.values()].filter((f) => f.choices.length > 1),
296
+ priceRange: Number.isFinite(lo) && Number.isFinite(hi) && hi > lo ? { min: Math.floor(lo), max: Math.ceil(hi), currency } : null,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Resolve the variant for the buyer's selections (optionName → choiceName). A product with no
302
+ * options resolves to its single variant; with options every option must be selected and match.
303
+ * null while incomplete. Selections start EMPTY — never pre-pick a first choice.
304
+ */
305
+ export function resolveVariant(detail: ProductDetail, selections: Record<string, string>): ProductVariant | null {
306
+ if (detail.options.length === 0) return detail.variants[0] ?? null;
307
+ if (!detail.options.every((o) => selections[o.name])) return null;
308
+ return detail.variants.find((v) => detail.options.every((o) => v.choices[o.name] === selections[o.name])) ?? null;
309
+ }