@wix/agent-skills 1.20.0 → 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 (152) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-headless-fast/SKILL.md +200 -34
  3. package/skills/wix-headless-fast/entry/skill.md +10 -2
  4. package/skills/wix-headless-fast/install/attach.mjs +253 -0
  5. package/skills/wix-headless-fast/install/deploy.mjs +109 -9
  6. package/skills/wix-headless-fast/install/fast-path.mjs +29 -34
  7. package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +224 -60
  8. package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
  9. package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
  10. package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
  11. package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
  12. package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
  13. package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
  14. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
  15. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
  16. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
  17. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
  18. package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
  19. package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
  20. package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
  21. package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
  22. package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +179 -33
  23. package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
  24. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
  25. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
  26. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
  27. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
  28. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
  29. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
  30. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
  31. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
  32. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
  33. package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
  34. package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
  35. package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
  36. package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +236 -62
  37. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
  38. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
  39. package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
  40. package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
  41. package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
  42. package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
  43. package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
  44. package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
  45. package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +239 -83
  46. package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
  47. package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
  48. package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
  49. package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
  50. package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
  51. package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
  52. package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
  53. package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
  54. package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
  55. package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
  56. package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
  57. package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
  58. package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
  59. package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
  60. package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +214 -26
  61. package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
  62. package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
  63. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
  64. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
  65. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
  66. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
  67. package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
  68. package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
  69. package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
  70. package/skills/wix-headless-fast/references/forms/seed/seed-forms.mjs +15 -8
  71. package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +314 -63
  72. package/skills/wix-headless-fast/references/members/app/components/members/LoginForm.tsx +1 -1
  73. package/skills/wix-headless-fast/references/members/app/wix/members/auth-core.ts +105 -0
  74. package/skills/wix-headless-fast/references/members/app/wix/members/auth.ts +36 -65
  75. package/skills/wix-headless-fast/references/members/app/wix/members/client.ts +1 -1
  76. package/skills/wix-headless-fast/references/members/app/wix/members/members-core.ts +43 -0
  77. package/skills/wix-headless-fast/references/members/app/wix/members/members.ts +11 -32
  78. package/skills/wix-headless-fast/references/members/rest/auth.ts +215 -0
  79. package/skills/wix-headless-fast/references/members/rest/members.ts +26 -0
  80. package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +238 -59
  81. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
  82. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
  83. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
  84. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
  85. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
  86. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
  87. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
  88. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
  89. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
  90. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
  91. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
  92. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
  93. package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
  94. package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
  95. package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +212 -51
  96. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
  97. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
  98. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
  99. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
  100. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
  101. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
  102. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
  103. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
  104. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
  105. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
  106. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
  107. package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
  108. package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
  109. package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
  110. package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +240 -47
  111. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
  112. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
  113. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
  114. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
  115. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
  116. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
  117. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
  118. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
  119. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
  120. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
  121. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
  122. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
  123. package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
  124. package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
  125. package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
  126. package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
  127. package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
  128. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +45 -4
  129. package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
  130. package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
  131. package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
  132. package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
  133. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +182 -63
  134. package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx +95 -0
  135. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +29 -58
  136. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -163
  137. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -300
  138. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
  139. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +49 -162
  140. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +309 -0
  141. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +47 -370
  142. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
  143. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
  144. package/skills/wix-headless-fast/references/storefront/rest/cart.ts +132 -0
  145. package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
  146. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +25 -9
  147. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +6 -35
  148. package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
  149. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +0 -40
  150. package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
  151. package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
  152. 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,130 +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
- // "Monthly plan · every 2 months · 6 payments" from the line's subscriptionInfo (Cart V2 carries the
69
- // plan on the line); "" for a one-time purchase. Never invented — every word comes from the plan.
70
- function subscriptionTerms(info: RawCart | undefined): string {
71
- if (!info) return "";
72
- const s: RawCart = info.subscriptionSettings ?? {};
73
- const unit: Record<string, string> = { DAY: "day", WEEK: "week", MONTH: "month", YEAR: "year" };
74
- const parts: string[] = [];
75
- const title = info.title?.original ?? info.title?.translated ?? "";
76
- if (title) parts.push(title);
77
- const u = unit[String(s.frequency ?? "")];
78
- if (u) parts.push(`every ${s.interval && s.interval > 1 ? `${s.interval} ${u}s` : u}`);
79
- if (!s.autoRenewal && s.billingCycles) parts.push(`${s.billingCycles} payments`);
80
- return parts.join(" · ");
81
- }
82
-
83
- function toLine(raw: RawCart, currency: string): CartLine {
84
- const descriptionLines: string[] = (raw.attributes?.descriptionLines ?? [])
85
- .map((d: RawCart) => {
86
- const label = d.name?.original ?? "";
87
- const value = d.plainText?.original ?? d.colorInfo?.original ?? "";
88
- return label && value ? `${label}: ${value}` : value || label;
89
- })
90
- .filter(Boolean);
91
- return {
92
- lineItemId: raw._id ?? "",
93
- productName: raw.name?.original ?? "",
94
- quantity: raw.quantityInfo?.confirmedQuantity ?? 0,
95
- unitPrice: formatMoney(raw.pricing?.unitPrice, currency),
96
- linePrice: formatMoney(raw.pricing?.totalPrice, currency),
97
- imageUrl: imgSrc(raw.attributes?.image, 300, 300),
98
- descriptionLines,
99
- status: raw.status ?? "IN_STOCK",
100
- subscription: subscriptionTerms(raw.subscriptionInfo),
101
- };
102
- }
103
-
104
- function toCart(raw: RawCart | null, subtotal: string, discount = ""): Cart {
105
- const currency = cartCurrency(raw);
106
- const lines = (raw?.lineItems ?? []).map((l: RawCart) => toLine(l, currency));
107
- return {
108
- lines,
109
- itemCount: lines.reduce((sum: number, l: CartLine) => sum + l.quantity, 0),
110
- subtotal,
111
- discount,
112
- currency,
113
- };
114
- }
115
-
116
- async function readCartWithSubtotal(raw: RawCart | null): Promise<Cart> {
67
+ async function readCartWithSubtotal(raw: Raw | null): Promise<Cart> {
117
68
  // Only estimate a cart WITH lines — on an absent/empty cart the endpoint returns 404.
118
- if (!raw?.lineItems?.length) return toCart(raw, "");
69
+ if (!raw?.lineItems?.length) return toCart(raw, "", "", imgSrc);
119
70
  // The authoritative after-discount subtotal and the CART-level discount come from the cart
120
71
  // estimate, never from hand-summing lines. Its delivery/tax/fees read "0" when nothing was
121
72
  // calculated — never render those as "Free"; shipping and tax resolve at checkout.
122
- let subtotal = "";
123
- let discount = "";
73
+ let totals = { subtotal: "", discount: "" };
124
74
  try {
125
- const estimate: RawCart = await cartApi.estimateCurrentCart();
126
- const summary = estimate?.summary?.priceSummary;
127
- const currency = cartCurrency(raw);
128
- subtotal = formatMoney(summary?.subtotal, currency);
129
- const d = summary?.discount;
130
- if (Number(d?.convertedAmount ?? d?.amount ?? 0) > 0) discount = formatMoney(d, currency);
75
+ totals = summaryTotals(await cartApi.estimateCurrentCart(), raw);
131
76
  } catch {
132
77
  /* estimate is a display nicety — the cart itself is still valid */
133
78
  }
134
- const cart = toCart(raw, subtotal, discount);
135
- 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[]);
136
81
  return cart;
137
82
  }
138
83
 
@@ -140,83 +85,36 @@ async function readCartWithSubtotal(raw: RawCart | null): Promise<Cart> {
140
85
  export async function fetchCart(): Promise<Cart> {
141
86
  try {
142
87
  const { cart } = await cartApi.getCurrentCart();
143
- return readCartWithSubtotal(cart as RawCart);
88
+ return readCartWithSubtotal(cart as Raw);
144
89
  } catch {
145
- return toCart(null, "");
90
+ return toCart(null, "", "", imgSrc);
146
91
  }
147
92
  }
148
93
 
149
- export interface AddToCartExtras {
150
- /** TEXT_CHOICES modifier selections: modifier key -> choice key. */
151
- modifierChoices?: Record<string, string>;
152
- /** FREE_TEXT modifier inputs: freeTextSettings key -> the buyer's text. */
153
- customTextFields?: Record<string, string>;
154
- /** The variant is out of stock but pre-orderable — sends preOrderRequested. */
155
- preorder?: boolean;
156
- /** A chosen recurring plan (subscriptionPricesInfo); omit for a one-time purchase. */
157
- subscriptionOptionId?: string;
158
- }
159
-
160
94
  /**
161
- * Add a product to the current cart.
162
- * For a product WITH options, `variantId` is mandatory — resolve it with
163
- * `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.
164
98
  */
165
- export async function addToCart(
166
- productId: string,
167
- variantId?: string | null,
168
- quantity = 1,
169
- { modifierChoices, customTextFields, preorder, subscriptionOptionId }: AddToCartExtras = {},
170
- ): Promise<Cart> {
171
- const options: Record<string, unknown> = {};
172
- if (variantId) options.variantId = variantId;
173
- if (modifierChoices && Object.keys(modifierChoices).length) options.options = modifierChoices;
174
- if (customTextFields && Object.keys(customTextFields).length) options.customTextFields = customTextFields;
175
- if (subscriptionOptionId) options.subscriptionOptionId = subscriptionOptionId;
176
- if (preorder) options.preOrderRequested = true;
177
-
99
+ export async function addToCart(productId: string, variantId?: string | null, quantity = 1, extras: AddToCartExtras = {}): Promise<Cart> {
100
+ const options = addOptions({ variantId, ...extras });
178
101
  const { cart } = await cartApi.addLineItemsToCurrentCart({
179
- catalogItems: [
180
- {
181
- quantity,
182
- catalogReference: {
183
- catalogItemId: productId,
184
- appId: WIX_STORES_APP_ID,
185
- ...(Object.keys(options).length ? { options } : {}),
186
- },
187
- },
188
- ],
102
+ catalogItems: [{ quantity, catalogReference: { catalogItemId: productId, appId: WIX_STORES_APP_ID, ...(Object.keys(options).length ? { options } : {}) } }],
189
103
  });
190
-
191
- const line = ((cart as RawCart)?.lineItems ?? []).find(
192
- (l: RawCart) =>
193
- l.source?.catalogReference?.catalogItemId === productId &&
194
- (!variantId || l.source?.catalogReference?.options?.variantId === variantId),
195
- );
196
- if (line?.status && line.status !== "IN_STOCK") {
197
- throw new Error(`This item isn't available right now (${line.status.toLowerCase().replace(/_/g, " ")}).`);
198
- }
199
- if (!line || line.quantityInfo?.confirmedQuantity === 0) {
200
- throw new Error(
201
- "The item couldn't be added. Make sure every required selection was made " +
202
- "(options for a product with variants, and all mandatory customizations).",
203
- );
204
- }
205
- return readCartWithSubtotal(cart as RawCart);
104
+ assertAdded(cart as Raw, productId, variantId);
105
+ return readCartWithSubtotal(cart as Raw);
206
106
  }
207
107
 
208
108
  /** Change a line's quantity. `lineItemId` is CartLine.lineItemId, never the product id. */
209
109
  export async function updateQuantity(lineItemId: string, quantity: number): Promise<Cart> {
210
- const { cart } = await cartApi.updateLineItemsInCurrentCart({
211
- lineItems: [{ lineItemId, quantity: { newQuantity: quantity } }],
212
- });
213
- return readCartWithSubtotal(cart as RawCart);
110
+ const { cart } = await cartApi.updateLineItemsInCurrentCart({ lineItems: [{ lineItemId, quantity: { newQuantity: quantity } }] });
111
+ return readCartWithSubtotal(cart as Raw);
214
112
  }
215
113
 
216
114
  /** Remove a line from the cart by CartLine.lineItemId. */
217
115
  export async function removeLine(lineItemId: string): Promise<Cart> {
218
116
  const { cart } = await cartApi.removeLineItemsFromCurrentCart([lineItemId]);
219
- return readCartWithSubtotal(cart as RawCart);
117
+ return readCartWithSubtotal(cart as Raw);
220
118
  }
221
119
 
222
120
  /**
@@ -227,23 +125,12 @@ export async function removeLine(lineItemId: string): Promise<Cart> {
227
125
  */
228
126
  export async function checkoutUrl(): Promise<string> {
229
127
  const { cart } = await cartApi.getCurrentCart();
230
- const raw = cart as RawCart;
231
- const lines: RawCart[] = raw?.lineItems ?? [];
232
- if (!lines.length) throw new Error("Your cart is empty.");
233
- const unavailable = lines.filter((l) => l.status && l.status !== "IN_STOCK");
234
- if (unavailable.length) {
235
- const names = unavailable.map((l) => l.name?.original).filter(Boolean).join(", ");
236
- throw new Error(`Some items are no longer available: ${names}.`);
237
- }
238
- if (!raw?._id) throw new Error("Checkout couldn't start: the cart has no id.");
239
-
128
+ const raw = cart as Raw;
129
+ assertCheckoutable(raw);
240
130
  const origin = typeof window !== "undefined" ? window.location.origin : "";
241
131
  const session = await redirects.createRedirectSession({
242
- ecomCheckout: { checkoutId: raw._id },
243
- callbacks: {
244
- postFlowUrl: origin ? `${origin}/` : undefined,
245
- thankYouPageUrl: origin ? `${origin}/` : undefined,
246
- },
132
+ ecomCheckout: { checkoutId: rawId(raw) },
133
+ callbacks: { postFlowUrl: origin ? `${origin}/` : undefined, thankYouPageUrl: origin ? `${origin}/` : undefined },
247
134
  });
248
135
  const url = session?.redirectSession?.fullUrl;
249
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
+ }