@wix/agent-skills 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +14 -112
  3. package/skills/wix-app/references/APP_TOOLS.md +2 -1
  4. package/skills/wix-app/references/BACKEND_EVENT.md +3 -3
  5. package/skills/wix-app/references/CUSTOM_ELEMENT_WIDGET.md +41 -152
  6. package/skills/wix-app/references/DASHBOARD_MODAL.md +4 -4
  7. package/skills/wix-app/references/SDK_MODULE_MAP.md +44 -0
  8. package/skills/wix-app/references/SITE_PLUGIN.md +36 -190
  9. package/skills/wix-app/references/app-tools/TOOLS.md +3 -1
  10. package/skills/wix-app/references/custom-element-widget/SETTINGS_PANEL.md +21 -0
  11. package/skills/wix-app/references/dashboard-page/ECOM_NAVIGATION.md +17 -0
  12. package/skills/wix-app/references/dashboard-plugin/SLOTS.md +4 -18
  13. package/skills/wix-app/references/site-plugin/EXAMPLES.md +1 -1
  14. package/skills/wix-headless-fast/SKILL.md +302 -51
  15. package/skills/wix-headless-fast/entry/skill.md +11 -2
  16. package/skills/wix-headless-fast/install/AGENTS.md +32 -0
  17. package/skills/wix-headless-fast/install/agents-md.mjs +39 -0
  18. package/skills/wix-headless-fast/install/attach.mjs +360 -0
  19. package/skills/wix-headless-fast/install/deploy.mjs +109 -9
  20. package/skills/wix-headless-fast/install/setup.mjs +239 -0
  21. package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +226 -60
  22. package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
  23. package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
  24. package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
  25. package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
  26. package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
  27. package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
  28. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
  29. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
  30. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
  31. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
  32. package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
  33. package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
  34. package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
  35. package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
  36. package/skills/wix-headless-fast/references/blog/seed/read-site.mjs +22 -0
  37. package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +181 -33
  38. package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
  39. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
  40. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
  41. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
  42. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
  43. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
  44. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
  45. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
  46. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
  47. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
  48. package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
  49. package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
  50. package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
  51. package/skills/wix-headless-fast/references/bookings/seed/read-site.mjs +28 -0
  52. package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +238 -62
  53. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
  54. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
  55. package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
  56. package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
  57. package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
  58. package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
  59. package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
  60. package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
  61. package/skills/wix-headless-fast/references/cms/seed/read-site.mjs +20 -0
  62. package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +241 -83
  63. package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
  64. package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
  65. package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
  66. package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
  67. package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
  68. package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
  69. package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
  70. package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
  71. package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
  72. package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
  73. package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
  74. package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
  75. package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
  76. package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
  77. package/skills/wix-headless-fast/references/events/seed/read-site.mjs +26 -0
  78. package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +216 -26
  79. package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
  80. package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
  81. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
  82. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
  83. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
  84. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
  85. package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
  86. package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
  87. package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
  88. package/skills/wix-headless-fast/references/forms/seed/read-site.mjs +20 -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 +316 -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/members/seed/read-site.mjs +15 -0
  100. package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +240 -59
  101. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
  102. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
  103. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
  104. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
  105. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
  106. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
  107. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
  108. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
  109. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
  110. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
  111. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
  112. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
  113. package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
  114. package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
  115. package/skills/wix-headless-fast/references/portfolio/seed/read-site.mjs +20 -0
  116. package/skills/wix-headless-fast/references/portfolio/seed/seed-portfolio.mjs +19 -21
  117. package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +214 -51
  118. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
  119. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
  120. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
  121. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
  122. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
  123. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
  124. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
  125. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
  126. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
  127. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
  128. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
  129. package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
  130. package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
  131. package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
  132. package/skills/wix-headless-fast/references/pricing-plans/seed/read-site.mjs +21 -0
  133. package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +242 -47
  134. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
  135. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
  136. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
  137. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
  138. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
  139. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
  140. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
  141. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
  142. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
  143. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
  144. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
  145. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
  146. package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
  147. package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
  148. package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
  149. package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
  150. package/skills/wix-headless-fast/references/restaurants/seed/read-site.mjs +25 -0
  151. package/skills/wix-headless-fast/references/restaurants/seed/seed-restaurants.mjs +25 -5
  152. package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
  153. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +45 -4
  154. package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
  155. package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
  156. package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
  157. package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
  158. package/skills/wix-headless-fast/references/shared/seed/read-site.mjs +99 -0
  159. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +186 -63
  160. package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx +95 -0
  161. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +29 -58
  162. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -163
  163. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -300
  164. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
  165. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +49 -162
  166. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +314 -0
  167. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +47 -370
  168. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
  169. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
  170. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +2 -0
  171. package/skills/wix-headless-fast/references/storefront/rest/cart.ts +132 -0
  172. package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
  173. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +31 -9
  174. package/skills/wix-headless-fast/references/storefront/seed/read-site.mjs +38 -0
  175. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +57 -50
  176. package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
  177. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-create-coupon.md +10 -19
  178. package/skills/wix-manage/references/events/create-wix-event.md +5 -1
  179. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +0 -40
  180. package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
  181. package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
  182. package/skills/wix-headless-fast/install/fast-path.mjs +0 -186
  183. package/skills/wix-headless-fast/install/pin-agents-md.mjs +0 -57
@@ -1,173 +1,39 @@
1
- // Product detail state: option selection → variant resolution → add-to-cart, plus modifier
2
- // inputs. This is the logic the turtle-run class of bugs comes from (adding variants[0]
3
- // regardless of the buyer's choice) — always drive a PDP through this hook.
1
+ // React binding of the product-detail store (wix/storefront/product-detail-store.ts) — option
2
+ // selection → variant resolution → add-to-cart lives there, framework-free. Always drive a PDP or
3
+ // quick-add picker through this hook (or the store): resolving variants by hand is where
4
+ // "added variants[0] regardless of the buyer's choice" comes from.
4
5
  //
5
- // SSR-friendly: pass the server-fetched ProductDetail as `initial` (Astro/Next); in a SPA
6
- // pass the slug and it fetches on mount.
7
- import { useEffect, useMemo, useState } from "react";
8
- import { fetchProductBySlug, resolveVariant } from "../../wix/storefront/catalog";
9
- import type { ProductDetail, ProductOption, ProductVariant } from "../../wix/storefront/types";
10
- import { useCart } from "./useCart";
6
+ // SSR-friendly: pass the server-fetched ProductDetail as `initial` (Astro/Next); in a SPA pass
7
+ // the slug and it fetches on mount.
8
+ import { useEffect, useRef, useSyncExternalStore } from "react";
9
+ import {
10
+ createProductDetailStore,
11
+ type ProductDetailState,
12
+ type ProductDetailStore,
13
+ type ProductDetailStoreOptions,
14
+ } from "../../wix/storefront/product-detail-store";
11
15
 
12
- export interface UseProductDetailOptions {
13
- initial?: ProductDetail | null;
14
- slug?: string;
15
- }
16
+ export { type OptionGroupView } from "../../wix/storefront/product-detail-store";
16
17
 
17
- export interface OptionGroupView extends ProductOption {
18
- choices: (ProductOption["choices"][number] & { selected: boolean })[];
19
- }
18
+ export type UseProductDetailOptions = ProductDetailStoreOptions;
20
19
 
21
- export interface UseProductDetail {
22
- /** null while loading (or when the slug doesn't resolve — check notFound). */
23
- product: ProductDetail | null;
24
- notFound: boolean;
25
- /** Option groups with per-choice `selected`, ready to render as pills/swatches. */
26
- optionGroups: OptionGroupView[];
27
- selectOption: (optionName: string, choiceName: string) => void;
28
- /** Modifier inputs keyed by modifier key. */
29
- modifierValues: Record<string, string>;
30
- setModifier: (key: string, value: string) => void;
31
- /** The resolved variant; null while the selection is incomplete. */
32
- variant: ProductVariant | null;
33
- /**
34
- * Price to display right now: the product RANGE ("€24.99 – €34.99", one value when equal) until
35
- * every option is picked, then the selected variant's price (an automatic discount applied).
36
- */
37
- price: string;
38
- /** Struck "was" price — only once a variant is resolved (or for a single-price product), never beside a range. */
39
- compareAtPrice: string | null;
40
- /** The resolved variant is out of stock but pre-orderable — label the action "Pre-order". */
41
- isPreorder: boolean;
42
- /** False until every option is selected and the resolved variant is in stock or pre-orderable. */
43
- canAdd: boolean;
44
- /**
45
- * Why the action is disabled — neutral guidance to render beside it ("Choose Size"), not an
46
- * error; null when the buyer can add. Promote to error styling only after they try to buy.
47
- */
48
- blockedReason: string | null;
49
- quantity: number;
50
- setQuantity: (n: number) => void;
51
- /** Adds the resolved variant to the cart (opens the drawer). Throws on refusal. */
52
- add: () => Promise<void>;
53
- adding: boolean;
54
- error: string | null;
55
- }
20
+ export type UseProductDetail = ProductDetailState &
21
+ Pick<ProductDetailStore, "selectOption" | "setModifier" | "setQuantity" | "add">;
56
22
 
57
23
  export function useProductDetail({ initial, slug }: UseProductDetailOptions): UseProductDetail {
58
- const [product, setProduct] = useState<ProductDetail | null>(initial ?? null);
59
- const [notFound, setNotFound] = useState(false);
60
- const [selections, setSelections] = useState<Record<string, string>>({});
61
- const [modifierValues, setModifierValues] = useState<Record<string, string>>({});
62
- const [quantity, setQuantity] = useState(1);
63
- const [adding, setAdding] = useState(false);
64
- const [error, setError] = useState<string | null>(null);
65
- const { addToCart } = useCart();
66
-
24
+ const ref = useRef<{ slug?: string; store: ProductDetailStore } | null>(null);
25
+ if (!ref.current || ref.current.slug !== slug) ref.current = { slug, store: createProductDetailStore({ initial, slug }) };
26
+ const store = ref.current.store;
67
27
  useEffect(() => {
68
- if (initial || !slug) return;
69
- let alive = true;
70
- fetchProductBySlug(slug)
71
- .then((p) => {
72
- if (!alive) return;
73
- setProduct(p);
74
- setNotFound(p === null);
75
- })
76
- .catch(() => alive && setNotFound(true));
77
- return () => {
78
- alive = false;
79
- };
80
- // eslint-disable-next-line react-hooks/exhaustive-deps
81
- }, [slug]);
82
-
83
- const variant = useMemo(
84
- () => (product ? resolveVariant(product, selections) : null),
85
- [product, selections],
86
- );
87
-
88
- const optionGroups: OptionGroupView[] = useMemo(
89
- () =>
90
- (product?.options ?? []).map((o) => ({
91
- ...o,
92
- choices: o.choices.map((c) => ({ ...c, selected: selections[o.name] === c.name })),
93
- })),
94
- [product, selections],
95
- );
96
-
97
- const missingModifier = (product?.modifiers ?? []).find(
98
- (m) => m.mandatory && (modifierValues[m.key] ?? "").length === 0,
99
- );
100
- const missingOptions = (product?.options ?? []).filter((o) => !selections[o.name]).map((o) => o.name);
101
- const selectionComplete = missingOptions.length === 0;
102
-
103
- // In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
104
- const isPreorder = !!variant && !variant.inStock && variant.preorderEnabled;
105
- const available = !!variant && (variant.inStock || variant.preorderEnabled);
106
-
107
- const canAdd = !!product && available && !missingModifier;
108
-
109
- const blockedReason: string | null = !product
110
- ? null
111
- : !selectionComplete
112
- ? `Choose ${missingOptions.join(" and ")}`
113
- : !variant
114
- ? "This combination isn't available"
115
- : !available
116
- ? "Out of stock"
117
- : missingModifier
118
- ? `Add ${missingModifier.name}`
119
- : null;
120
-
121
- // Before every option is picked, the range — never an empty price, never a lone struck minimum.
122
- const isRange = !!product && product.price !== product.maxPrice && !!product.maxPrice;
123
- const rangeDisplay = product ? (isRange ? `${product.price} – ${product.maxPrice}` : product.price) : "";
124
-
125
- async function add(): Promise<void> {
126
- if (!product || !variant) return;
127
- setAdding(true);
128
- setError(null);
129
- try {
130
- const choiceModifiers: Record<string, string> = {};
131
- const textModifiers: Record<string, string> = {};
132
- for (const m of product.modifiers) {
133
- const value = modifierValues[m.key];
134
- if (!value) continue;
135
- if (m.type === "text") textModifiers[m.key] = value;
136
- else choiceModifiers[m.key] = value;
137
- }
138
- await addToCart(product.id, variant.variantId, quantity, {
139
- modifierChoices: choiceModifiers,
140
- customTextFields: textModifiers,
141
- preorder: isPreorder,
142
- });
143
- } catch (e) {
144
- setError(e instanceof Error ? e.message : String(e));
145
- throw e;
146
- } finally {
147
- setAdding(false);
148
- }
149
- }
150
-
28
+ store.start();
29
+ return () => store.stop();
30
+ }, [store]);
31
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
151
32
  return {
152
- product,
153
- notFound,
154
- optionGroups,
155
- selectOption: (optionName, choiceName) => {
156
- setSelections((s) => ({ ...s, [optionName]: choiceName }));
157
- setQuantity(1); // the ceiling belongs to the newly resolved variant
158
- },
159
- modifierValues,
160
- setModifier: (key, value) => setModifierValues((v) => ({ ...v, [key]: value })),
161
- variant,
162
- price: variant?.price || rangeDisplay,
163
- compareAtPrice: variant ? variant.compareAtPrice : isRange ? null : (product?.compareAtPrice ?? null),
164
- isPreorder,
165
- canAdd,
166
- blockedReason,
167
- quantity,
168
- setQuantity,
169
- add,
170
- adding,
171
- error,
33
+ ...state,
34
+ selectOption: store.selectOption,
35
+ setModifier: store.setModifier,
36
+ setQuantity: store.setQuantity,
37
+ add: store.add,
172
38
  };
173
39
  }
@@ -1,308 +1,34 @@
1
- // Catalog listing for a shop or category surface: category, sort, price/stock/search filters,
2
- // option facets, and cursor paging — all applied by Wix across the whole catalog (searchCatalog),
3
- // never to a page already in hand. A changed selection starts a fresh cursor chain.
4
- //
5
- // URL state: sort, filters, facet choices (and a live category switch on /shop) are read from the
6
- // query string on mount and written back with replaceState — a filtered gallery is a link a
7
- // shopper can share, reload, and step back to. Paging stays out of the URL.
8
- //
9
- // SSR-friendly: pass `initialProducts`/`initialCategories` (Astro frontmatter) and they render
10
- // immediately — the hook still revalidates the first page once on mount to open the cursor
11
- // chain, replacing the seed without a skeleton flash. A /category/[slug] page passes
12
- // `initialCategoryId` so the first query is already scoped.
13
- import { useCallback, useEffect, useRef, useState } from "react";
14
- import {
15
- CATALOG_SORTS,
16
- fetchCategories,
17
- fetchFacetData,
18
- searchCatalog,
19
- type CatalogSort,
20
- } from "../../wix/storefront/catalog";
21
- import type { Category, Facet, PriceRange, ProductSummary } from "../../wix/storefront/types";
1
+ // React binding of the shop store (wix/storefront/shop-store.ts) — the listing state machine
2
+ // lives there, framework-free; this hook subscribes to one instance per mounted listing and
3
+ // exposes its state and actions under one name. Astro islands and React SPAs use this; a static
4
+ // page, Vue, or Svelte uses the store directly.
5
+ import { useEffect, useRef, useSyncExternalStore } from "react";
6
+ import { createShopStore, type ShopState, type ShopStore, type ShopStoreOptions } from "../../wix/storefront/shop-store";
22
7
 
23
- export const SORTS = CATALOG_SORTS;
8
+ export { SORTS, type ShopFilters } from "../../wix/storefront/shop-store";
24
9
 
25
- export interface ShopFilters {
26
- minPrice?: number | string;
27
- maxPrice?: number | string;
28
- inStockOnly?: boolean;
29
- /** Name search, max 100 chars. */
30
- search?: string;
31
- }
32
-
33
- export interface UseShopOptions {
34
- initialProducts?: ProductSummary[];
35
- initialCategories?: Category[];
36
- /** Scope the listing to one category from the first render (a /category/[slug] page). */
37
- initialCategoryId?: string | null;
38
- pageSize?: number;
39
- }
40
-
41
- export interface UseShop {
42
- /** null while the first load is in flight — render skeletons, not an empty state. */
43
- products: ProductSummary[] | null;
44
- /** Matching products across the whole catalog for the current selection; null until known. */
45
- total: number | null;
46
- categories: Category[];
47
- /** null = "all products". */
48
- activeCategoryId: string | null;
49
- setActiveCategoryId: (id: string | null) => void;
50
- sort: CatalogSort;
51
- setSort: (sort: CatalogSort) => void;
52
- filters: ShopFilters;
53
- setFilters: (filters: ShopFilters) => void;
54
- /** The filterable options of the current scope (Color, Size…), from the catalog itself. */
55
- facets: Facet[];
56
- /** Lowest and highest product price in the scope — the price slider's bounds; null until known or when all prices are equal. */
57
- priceRange: PriceRange | null;
58
- /** Selected facet choice ids — products carrying ANY of them match. */
59
- selectedChoiceIds: string[];
60
- toggleChoice: (choiceId: string) => void;
61
- /** Clears price/stock/search filters and facet selections (keeps category and sort). */
62
- clearFilters: () => void;
63
- /** True when any filter or facet is active. */
64
- hasActiveFilters: boolean;
65
- loading: boolean;
66
- error: string | null;
67
- retry: () => void;
68
- hasMore: boolean;
69
- loadMore: () => Promise<void>;
70
- loadingMore: boolean;
71
- }
72
-
73
- const URL_KEYS = { sort: "sort", min: "min", max: "max", stock: "stock", q: "q", choice: "choice", category: "category" } as const;
74
-
75
- function readUrlState(): { sort?: CatalogSort; filters: ShopFilters; choiceIds: string[]; categoryId?: string | null } | null {
76
- if (typeof window === "undefined") return null;
77
- const p = new URLSearchParams(window.location.search);
78
- const sort = p.get(URL_KEYS.sort);
79
- const filters: ShopFilters = {};
80
- if (p.get(URL_KEYS.min)) filters.minPrice = p.get(URL_KEYS.min)!;
81
- if (p.get(URL_KEYS.max)) filters.maxPrice = p.get(URL_KEYS.max)!;
82
- if (p.get(URL_KEYS.stock) === "1") filters.inStockOnly = true;
83
- if (p.get(URL_KEYS.q)) filters.search = p.get(URL_KEYS.q)!;
84
- return {
85
- sort: sort && sort in CATALOG_SORTS ? (sort as CatalogSort) : undefined,
86
- filters,
87
- choiceIds: p.getAll(URL_KEYS.choice),
88
- categoryId: p.has(URL_KEYS.category) ? p.get(URL_KEYS.category) : undefined,
89
- };
90
- }
91
-
92
- interface PageState {
93
- key: string | null;
94
- products: ProductSummary[] | null;
95
- total: number | null;
96
- cursor: string | null;
97
- error: string | null;
98
- loadingMore: boolean;
99
- }
10
+ export type UseShopOptions = ShopStoreOptions;
100
11
 
101
- export function useShop({
102
- initialProducts,
103
- initialCategories,
104
- initialCategoryId = null,
105
- pageSize = 24,
106
- }: UseShopOptions = {}): UseShop {
107
- const [categories, setCategories] = useState<Category[]>(initialCategories ?? []);
108
- const [activeCategoryId, setActiveCategoryId] = useState<string | null>(initialCategoryId);
109
- const [sort, setSort] = useState<CatalogSort>("featured");
110
- const [filters, setFilters] = useState<ShopFilters>({});
111
- const [selectedChoiceIds, setSelectedChoiceIds] = useState<string[]>([]);
112
- const [facets, setFacets] = useState<Facet[]>([]);
113
- const [priceRange, setPriceRange] = useState<PriceRange | null>(null);
114
- const [attempt, setAttempt] = useState(0);
115
- const [page, setPage] = useState<PageState>({
116
- key: null,
117
- products: initialProducts ?? null,
118
- total: null,
119
- cursor: null,
120
- error: null,
121
- loadingMore: false,
122
- });
123
- const generation = useRef(0);
124
- const pendingMore = useRef<object | null>(null);
125
- const firstWrite = useRef(true);
12
+ export type UseShop = ShopState &
13
+ Pick<ShopStore, "setActiveCategoryId" | "setSort" | "setFilters" | "toggleChoice" | "clearFilters" | "retry" | "loadMore">;
126
14
 
127
- // Adopt the URL's selection once, after hydration (the server rendered the defaults).
15
+ export function useShop(options: UseShopOptions = {}): UseShop {
16
+ const ref = useRef<ShopStore | null>(null);
17
+ if (!ref.current) ref.current = createShopStore(options);
18
+ const store = ref.current;
128
19
  useEffect(() => {
129
- const u = readUrlState();
130
- if (!u) return;
131
- if (u.sort) setSort(u.sort);
132
- if (Object.keys(u.filters).length) setFilters(u.filters);
133
- if (u.choiceIds.length) setSelectedChoiceIds(u.choiceIds);
134
- if (u.categoryId !== undefined && u.categoryId !== initialCategoryId) setActiveCategoryId(u.categoryId || null);
135
- // eslint-disable-next-line react-hooks/exhaustive-deps
136
- }, []);
137
-
138
- const key = JSON.stringify([
139
- pageSize,
140
- activeCategoryId,
141
- sort,
142
- filters.minPrice ?? null,
143
- filters.maxPrice ?? null,
144
- !!filters.inStockOnly,
145
- filters.search ?? "",
146
- [...selectedChoiceIds].sort(),
147
- attempt,
148
- ]);
149
- const currentKey = useRef(key);
150
- currentKey.current = key;
151
-
152
- // Mirror the selection into the query string (defaults are omitted; nothing else in the URL is touched).
153
- useEffect(() => {
154
- if (typeof window === "undefined") return;
155
- // The mount run still holds the defaults (the adopted URL state lands on the next render) —
156
- // writing now would erase the very params being adopted.
157
- if (firstWrite.current) {
158
- firstWrite.current = false;
159
- return;
160
- }
161
- const p = new URLSearchParams(window.location.search);
162
- for (const k of Object.values(URL_KEYS)) p.delete(k);
163
- if (sort !== "featured") p.set(URL_KEYS.sort, sort);
164
- if (filters.minPrice != null && filters.minPrice !== "") p.set(URL_KEYS.min, String(filters.minPrice));
165
- if (filters.maxPrice != null && filters.maxPrice !== "") p.set(URL_KEYS.max, String(filters.maxPrice));
166
- if (filters.inStockOnly) p.set(URL_KEYS.stock, "1");
167
- if (filters.search?.trim()) p.set(URL_KEYS.q, filters.search.trim());
168
- for (const id of selectedChoiceIds) p.append(URL_KEYS.choice, id);
169
- if (activeCategoryId !== initialCategoryId) p.set(URL_KEYS.category, activeCategoryId ?? "");
170
- const qs = p.toString();
171
- const next = `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
172
- if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`) window.history.replaceState(window.history.state, "", next);
173
- }, [key, sort, filters, selectedChoiceIds, activeCategoryId, initialCategoryId]);
174
-
175
- useEffect(() => {
176
- if (initialCategories) return;
177
- let alive = true;
178
- fetchCategories().then((list) => alive && setCategories(list)).catch(() => alive && setCategories([]));
179
- return () => {
180
- alive = false;
181
- };
182
- // eslint-disable-next-line react-hooks/exhaustive-deps
183
- }, []);
184
-
185
- // Facets follow the category scope (a Size facet in "Donuts" is meaningless).
186
- useEffect(() => {
187
- let alive = true;
188
- fetchFacetData({ categoryId: activeCategoryId }).then((d) => {
189
- if (!alive) return;
190
- setFacets(d.facets);
191
- setPriceRange(d.priceRange);
192
- });
193
- return () => {
194
- alive = false;
195
- };
196
- }, [activeCategoryId]);
197
-
198
- useEffect(() => {
199
- const id = ++generation.current;
200
- let alive = true;
201
- pendingMore.current = null;
202
- // Keep the SSR seed (or the previous page) on screen while the fresh query runs;
203
- // only a selection change with no seed shows skeletons.
204
- setPage((p) => ({ key, products: p.key === null ? p.products : null, total: null, cursor: null, error: null, loadingMore: false }));
205
- const [limit, categoryId, selectedSort, minPrice, maxPrice, inStockOnly, search, choiceIds] = JSON.parse(key);
206
- searchCatalog({ limit, categoryId, sort: selectedSort, minPrice, maxPrice, inStockOnly, search, choiceIds })
207
- .then((res) => {
208
- if (alive && generation.current === id && currentKey.current === key) {
209
- setPage({ key, products: res.products, total: res.total, cursor: res.nextCursor, error: null, loadingMore: false });
210
- }
211
- })
212
- .catch((e) => {
213
- if (alive && generation.current === id && currentKey.current === key) {
214
- setPage({
215
- key,
216
- products: [],
217
- total: null,
218
- cursor: null,
219
- error: e instanceof Error ? e.message : "Couldn't load products.",
220
- loadingMore: false,
221
- });
222
- }
223
- });
224
- return () => {
225
- alive = false;
226
- generation.current++;
227
- };
228
- // eslint-disable-next-line react-hooks/exhaustive-deps
229
- }, [key]);
230
-
231
- const loadMore = useCallback(async () => {
232
- if (page.key !== key || !page.cursor || pendingMore.current) return;
233
- const request = { generation: generation.current, key };
234
- pendingMore.current = request; // blocks repeated clicks before React rerenders
235
- const isCurrent = () => generation.current === request.generation && currentKey.current === key;
236
- setPage((p) => ({ ...p, loadingMore: true, error: null }));
237
- try {
238
- const res = await searchCatalog({ limit: pageSize, cursor: page.cursor });
239
- if (isCurrent())
240
- setPage((p) => {
241
- const seen = new Set((p.products ?? []).map((product) => product.id));
242
- return {
243
- ...p,
244
- products: [
245
- ...(p.products ?? []),
246
- ...res.products.filter((product) => {
247
- if (seen.has(product.id)) return false;
248
- seen.add(product.id);
249
- return true;
250
- }),
251
- ],
252
- cursor: res.nextCursor,
253
- };
254
- });
255
- } catch (e) {
256
- if (isCurrent())
257
- setPage((p) => ({ ...p, error: e instanceof Error ? e.message : "Couldn't load more products." }));
258
- } finally {
259
- if (pendingMore.current === request) pendingMore.current = null;
260
- if (isCurrent()) setPage((p) => ({ ...p, loadingMore: false }));
261
- }
262
- }, [key, page.key, page.cursor, pageSize]);
263
-
264
- const retry = useCallback(() => setAttempt((n) => n + 1), []);
265
- const toggleChoice = useCallback(
266
- (choiceId: string) =>
267
- setSelectedChoiceIds((ids) => (ids.includes(choiceId) ? ids.filter((x) => x !== choiceId) : [...ids, choiceId])),
268
- [],
269
- );
270
- const clearFilters = useCallback(() => {
271
- setFilters({});
272
- setSelectedChoiceIds([]);
273
- }, []);
274
-
275
- const current = page.key === key;
276
- const seeded = page.key === null && page.products !== null; // SSR seed, first query in flight
277
- // Seeded content on screen is not "loading" — skeletons are only for a genuinely empty wait.
278
- const loading = (!current && !seeded) || (current && page.products === null);
279
- const hasActiveFilters =
280
- selectedChoiceIds.length > 0 ||
281
- !!filters.inStockOnly ||
282
- filters.minPrice != null && filters.minPrice !== "" ||
283
- filters.maxPrice != null && filters.maxPrice !== "" ||
284
- !!(filters.search && filters.search.trim());
20
+ store.start();
21
+ return () => store.stop();
22
+ }, [store]);
23
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
285
24
  return {
286
- products: current || seeded ? page.products : null,
287
- total: current ? page.total : null,
288
- categories,
289
- activeCategoryId,
290
- setActiveCategoryId,
291
- sort,
292
- setSort,
293
- filters,
294
- setFilters,
295
- facets,
296
- priceRange,
297
- selectedChoiceIds,
298
- toggleChoice,
299
- clearFilters,
300
- hasActiveFilters,
301
- loading,
302
- error: current ? page.error : null,
303
- retry,
304
- hasMore: current && !!page.cursor,
305
- loadMore,
306
- loadingMore: current && page.loadingMore,
25
+ ...state,
26
+ setActiveCategoryId: store.setActiveCategoryId,
27
+ setSort: store.setSort,
28
+ setFilters: store.setFilters,
29
+ toggleChoice: store.toggleChoice,
30
+ clearFilters: store.clearFilters,
31
+ retry: store.retry,
32
+ loadMore: store.loadMore,
307
33
  };
308
34
  }
@@ -0,0 +1,121 @@
1
+ // Cart rules and DTO mapping — transport-agnostic, imported by both ./cart.ts (SDK) and the REST
2
+ // twin in references/storefront/rest/cart.ts (fetch). Raw Cart V2 entities may carry `_id` (SDK)
3
+ // or `id` (REST). Has its own formatMoney (a copy of ../money.ts) so it stands alone when stripped.
4
+ import type { Cart, CartLine } from "./types";
5
+ import type { ImgSrc, Raw } from "./catalog-core";
6
+
7
+ /** Public app id of the Wix Stores catalog — required inside every catalogReference. */
8
+ export const WIX_STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
9
+
10
+ export const rawId = (raw: Raw | undefined | null): string => raw?._id ?? raw?.id ?? "";
11
+
12
+ /**
13
+ * Cart V2 money is ConvertedMoney { amount, convertedAmount } with NO formatted string, and the
14
+ * currency lives on the cart, not on the money. Format with the buyer's display currency when
15
+ * present, else the site's. Never hardcode "$" or assume USD.
16
+ */
17
+ export function formatMoney(money: Raw | null | undefined, currencyCode: string | null | undefined): string {
18
+ const value = money?.convertedAmount ?? money?.amount;
19
+ if (value == null || value === "") return "";
20
+ const currency = currencyCode || "USD";
21
+ try {
22
+ return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(Number(value));
23
+ } catch {
24
+ return `${value} ${currency}`;
25
+ }
26
+ }
27
+
28
+ export const cartCurrency = (raw: Raw | null | undefined): string =>
29
+ raw?.customerInfo?.currencyCode ?? raw?.businessInfo?.currencyCode ?? "";
30
+
31
+ /**
32
+ * "Monthly plan · every 2 months · 6 payments" from the line's subscriptionInfo (Cart V2 carries
33
+ * the plan on the line); "" for a one-time purchase. Every word comes from the plan — a
34
+ * subscription line must read as one in the cart, never a generic "recurring".
35
+ */
36
+ export function subscriptionTerms(info: Raw | undefined): string {
37
+ if (!info) return "";
38
+ const s: Raw = info.subscriptionSettings ?? {};
39
+ const unit: Record<string, string> = { DAY: "day", WEEK: "week", MONTH: "month", YEAR: "year" };
40
+ const parts: string[] = [];
41
+ const title = info.title?.original ?? info.title?.translated ?? "";
42
+ if (title) parts.push(title);
43
+ const u = unit[String(s.frequency ?? "")];
44
+ if (u) parts.push(`every ${s.interval && s.interval > 1 ? `${s.interval} ${u}s` : u}`);
45
+ if (!s.autoRenewal && s.billingCycles) parts.push(`${s.billingCycles} payments`);
46
+ return parts.join(" · ");
47
+ }
48
+
49
+ export function toLine(raw: Raw, currency: string, imgSrc: ImgSrc): CartLine {
50
+ return {
51
+ lineItemId: rawId(raw), // the LINE id — what update/remove take, never the product id
52
+ productName: raw.name?.original ?? "",
53
+ quantity: raw.quantityInfo?.confirmedQuantity ?? 0,
54
+ unitPrice: formatMoney(raw.pricing?.unitPrice, currency),
55
+ linePrice: formatMoney(raw.pricing?.totalPrice, currency),
56
+ imageUrl: imgSrc(raw.attributes?.image, 300, 300),
57
+ descriptionLines: ((raw.attributes?.descriptionLines ?? []) as Raw[])
58
+ .map((d) => {
59
+ const label = d.name?.original ?? "", value = d.plainText?.original ?? d.colorInfo?.original ?? "";
60
+ return label && value ? `${label}: ${value}` : value || label;
61
+ })
62
+ .filter(Boolean),
63
+ status: raw.status ?? "IN_STOCK", // not IN_STOCK → the line can't be checked out as-is
64
+ subscription: subscriptionTerms(raw.subscriptionInfo),
65
+ };
66
+ }
67
+
68
+ export function toCart(raw: Raw | null, subtotal: string, discount: string, imgSrc: ImgSrc): Cart {
69
+ const currency = cartCurrency(raw);
70
+ const lines = ((raw?.lineItems ?? []) as Raw[]).map((l) => toLine(l, currency, imgSrc));
71
+ return { lines, itemCount: lines.reduce((n, l) => n + l.quantity, 0), subtotal, discount, currency };
72
+ }
73
+
74
+ /** The after-discount subtotal and the CART-level discount from an estimate; "" when unknown. */
75
+ export function summaryTotals(estimate: Raw | null | undefined, raw: Raw): { subtotal: string; discount: string } {
76
+ const s = estimate?.summary?.priceSummary;
77
+ const currency = cartCurrency(raw);
78
+ const d = s?.discount;
79
+ return {
80
+ subtotal: formatMoney(s?.subtotal, currency),
81
+ discount: Number(d?.convertedAmount ?? d?.amount ?? 0) > 0 ? formatMoney(d, currency) : "",
82
+ };
83
+ }
84
+
85
+ /** The catalogReference.options object for an add — omits every key the buyer didn't use. */
86
+ export function addOptions({ variantId, modifierChoices, customTextFields, subscriptionOptionId, preorder }: {
87
+ variantId?: string | null; modifierChoices?: Record<string, string>; customTextFields?: Record<string, string>;
88
+ subscriptionOptionId?: string; preorder?: boolean;
89
+ }): Raw {
90
+ const o: Raw = {};
91
+ if (variantId) o.variantId = variantId; // REQUIRED for any product with options
92
+ if (modifierChoices && Object.keys(modifierChoices).length) o.options = modifierChoices; // MODIFIERS only, never Size/Color
93
+ if (customTextFields && Object.keys(customTextFields).length) o.customTextFields = customTextFields;
94
+ if (subscriptionOptionId) o.subscriptionOptionId = subscriptionOptionId;
95
+ if (preorder) o.preOrderRequested = true;
96
+ return o;
97
+ }
98
+
99
+ /** Read the add result — a refused add still returns 200: a line whose status isn't IN_STOCK, or no line, is a refusal. */
100
+ export function assertAdded(cart: Raw | null | undefined, productId: string, variantId?: string | null): void {
101
+ const line = ((cart?.lineItems ?? []) as Raw[]).find(
102
+ (l) => l.source?.catalogReference?.catalogItemId === productId && (!variantId || l.source?.catalogReference?.options?.variantId === variantId),
103
+ );
104
+ if (line?.status && line.status !== "IN_STOCK") {
105
+ throw new Error(`This item isn't available right now (${String(line.status).toLowerCase().replace(/_/g, " ")}).`);
106
+ }
107
+ if (!line || line.quantityInfo?.confirmedQuantity === 0) {
108
+ throw new Error("The item couldn't be added. Make sure every required selection was made (options for a product with variants, and all mandatory customizations).");
109
+ }
110
+ }
111
+
112
+ /** Refuse checkout for an empty cart or any line not IN_STOCK — say which. */
113
+ export function assertCheckoutable(raw: Raw | null): void {
114
+ const lines: Raw[] = raw?.lineItems ?? [];
115
+ if (!lines.length) throw new Error("Your cart is empty.");
116
+ const unavailable = lines.filter((l) => l.status && l.status !== "IN_STOCK");
117
+ if (unavailable.length) {
118
+ throw new Error(`Some items are no longer available: ${unavailable.map((l) => l.name?.original).filter(Boolean).join(", ")}.`);
119
+ }
120
+ if (!rawId(raw)) throw new Error("Checkout couldn't start: the cart has no id.");
121
+ }