@base44/app-plugin-commerce 0.1.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Variant selection helpers for the storefront product page.
3
+ *
4
+ * A variable product is defined on two levels: the parent's `attributes[]`
5
+ * entries with `variation: true` are the *axes* (Size, Color), and each record
6
+ * in `variations[]` is one combination of options on those axes. The product
7
+ * page must therefore present **one control per axis** — never a flat list of
8
+ * `Red-S`, `Red-M`, `Blue-S`, ... — and map the customer's selection onto a
9
+ * variation itself: the cart API does no attribute matching, it takes a
10
+ * `variation_id`. These helpers own that mapping in both directions.
11
+ *
12
+ * Framework-free, dependency-free, no I/O — feed them the `{ product,
13
+ * variations }` pair from `commerce/storefront-catalog` `get-product` (the
14
+ * admin's variation editor can use them too). See
15
+ * `skills/commerce/references/storefront-product-page.md` for the UI rules.
16
+ *
17
+ * A `selection` throughout is a plain object keyed by axis key (see
18
+ * `attributeKey`): `{ "<color-attr-id>": "Red", "<size-attr-id>": "M" }`.
19
+ */
20
+
21
+ /** Availability of a single option, as returned by `optionAvailability`. */
22
+ export const OPTION_AVAILABLE = "available";
23
+ export const OPTION_OUT_OF_STOCK = "out_of_stock";
24
+ export const OPTION_UNAVAILABLE = "unavailable";
25
+
26
+ const PURCHASABLE_STOCK = new Set(["instock", "onbackorder"]);
27
+
28
+ /**
29
+ * Stable key for an attribute or a variation-attribute entry.
30
+ *
31
+ * Global attributes carry an `attribute_id`; custom (product-local) ones have
32
+ * it empty, so an id-only key would collide across them — fall back to `name`.
33
+ *
34
+ * @param {{attribute_id?: string, name?: string}} attribute
35
+ * @returns {string}
36
+ */
37
+ export function attributeKey(attribute) {
38
+ return attribute?.attribute_id || attribute?.name || "";
39
+ }
40
+
41
+ /**
42
+ * The product's variation axes: `attributes` with `variation: true`, ordered by
43
+ * `position`. These — and only these — become selectors on the product page;
44
+ * `visible: true` non-variation attributes belong in a spec table.
45
+ *
46
+ * @param {object} product
47
+ * @returns {Array<object>} the matching `product.attributes` entries
48
+ */
49
+ export function variationAxes(product) {
50
+ return (product?.attributes ?? [])
51
+ .filter((a) => a?.variation)
52
+ .slice()
53
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
54
+ }
55
+
56
+ /** The variation's option on one axis, or "" when it accepts any value. */
57
+ function variationOption(variation, key) {
58
+ const entry = (variation?.attributes ?? []).find((a) => attributeKey(a) === key);
59
+ return entry?.option ?? "";
60
+ }
61
+
62
+ /** Variations that are actually offered (publishable). */
63
+ function publishable(variations) {
64
+ return (variations ?? []).filter((v) => (v?.status ?? "publish") === "publish");
65
+ }
66
+
67
+ function isPurchasable(variation) {
68
+ return PURCHASABLE_STOCK.has(variation?.stock_status ?? "instock");
69
+ }
70
+
71
+ /**
72
+ * Does a variation satisfy a (possibly partial) selection? An empty option on
73
+ * the variation is a wildcard, and axes absent from `selection` are ignored —
74
+ * so a partial selection matches every variation still reachable from it.
75
+ *
76
+ * @param {object} product
77
+ * @param {object} variation
78
+ * @param {Record<string, string>} selection
79
+ * @returns {boolean}
80
+ */
81
+ export function variationMatches(product, variation, selection) {
82
+ return variationAxes(product).every((axis) => {
83
+ const key = attributeKey(axis);
84
+ const chosen = selection?.[key];
85
+ if (!chosen) return true;
86
+ const option = variationOption(variation, key);
87
+ return !option || option === chosen;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Every publishable variation still reachable from a partial selection —
93
+ * the basis for narrowing images, price ranges and option availability before
94
+ * the customer has picked every axis.
95
+ *
96
+ * @returns {Array<object>}
97
+ */
98
+ export function matchingVariations(product, variations, selection = {}) {
99
+ return publishable(variations).filter((v) => variationMatches(product, v, selection));
100
+ }
101
+
102
+ /** Has the customer picked an option on every axis? */
103
+ export function isSelectionComplete(product, selection = {}) {
104
+ const axes = variationAxes(product);
105
+ return axes.length > 0 && axes.every((a) => Boolean(selection?.[attributeKey(a)]));
106
+ }
107
+
108
+ /**
109
+ * selection → variation. Returns the variation to add to the cart, or `null`
110
+ * while the selection is incomplete or matches nothing.
111
+ *
112
+ * @param {object} product
113
+ * @param {Array<object>} variations
114
+ * @param {Record<string, string>} selection
115
+ * @returns {object|null}
116
+ */
117
+ export function findVariation(product, variations, selection = {}) {
118
+ if (!isSelectionComplete(product, selection)) return null;
119
+ return matchingVariations(product, variations, selection)[0] ?? null;
120
+ }
121
+
122
+ /**
123
+ * variation → selection. The inverse of `findVariation`: use it to hydrate the
124
+ * controls from a variation you already have (a cart line, an order item, a
125
+ * `?variation=` link).
126
+ *
127
+ * @param {object} product
128
+ * @param {object} variation
129
+ * @returns {Record<string, string>} omits axes the variation leaves as "any"
130
+ */
131
+ export function selectionForVariation(product, variation) {
132
+ const selection = {};
133
+ for (const axis of variationAxes(product)) {
134
+ const key = attributeKey(axis);
135
+ const option = variationOption(variation, key);
136
+ if (option) selection[key] = option;
137
+ }
138
+ return selection;
139
+ }
140
+
141
+ /**
142
+ * The options to render per axis, in the parent's declared order, dropping any
143
+ * option no publishable variation offers (a stale `attributes[].options` entry
144
+ * would otherwise render a permanently dead choice).
145
+ *
146
+ * @returns {Array<{key: string, name: string, attribute_id: string, options: string[]}>}
147
+ */
148
+ export function axisOptions(product, variations) {
149
+ const live = publishable(variations);
150
+ return variationAxes(product).map((axis) => {
151
+ const key = attributeKey(axis);
152
+ const offered = new Set();
153
+ let wildcard = false;
154
+ for (const v of live) {
155
+ const option = variationOption(v, key);
156
+ if (option) offered.add(option);
157
+ else wildcard = true; // a wildcard variation covers every declared option
158
+ }
159
+ const declared = axis.options ?? [];
160
+ const options = wildcard ? declared.slice() : declared.filter((o) => offered.has(o));
161
+ // options present on variations but missing from the parent's list, appended
162
+ for (const option of offered) if (!options.includes(option)) options.push(option);
163
+ return { key, name: axis.name ?? "", attribute_id: axis.attribute_id ?? "", options };
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Per-option availability given the rest of the current selection, so the UI
169
+ * can disable rather than hide impossible choices:
170
+ * `available` — a purchasable variation exists
171
+ * `out_of_stock` — the combination exists but every match is out of stock
172
+ * `unavailable` — no such combination
173
+ *
174
+ * @returns {Record<string, Record<string, string>>} `{ [axisKey]: { [option]: state } }`
175
+ */
176
+ export function optionAvailability(product, variations, selection = {}) {
177
+ const out = {};
178
+ for (const axis of axisOptions(product, variations)) {
179
+ out[axis.key] = {};
180
+ for (const option of axis.options) {
181
+ const hypothetical = { ...selection, [axis.key]: option };
182
+ const hits = matchingVariations(product, variations, hypothetical);
183
+ out[axis.key][option] = !hits.length
184
+ ? OPTION_UNAVAILABLE
185
+ : hits.some(isPurchasable)
186
+ ? OPTION_AVAILABLE
187
+ : OPTION_OUT_OF_STOCK;
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * Apply a click to the current selection. Keeps the just-picked option and
195
+ * drops only the axes that now conflict, so the customer can always switch a
196
+ * colour without dead-ending on an unavailable size. Passing a falsy `option`
197
+ * clears the axis.
198
+ *
199
+ * @returns {Record<string, string>} the next selection (never mutates the input)
200
+ */
201
+ export function selectOption(product, variations, selection, axisKey, option) {
202
+ if (!option) {
203
+ const { [axisKey]: _cleared, ...rest } = selection ?? {};
204
+ return rest;
205
+ }
206
+ const next = { ...(selection ?? {}), [axisKey]: option };
207
+ if (matchingVariations(product, variations, next).length) return next;
208
+
209
+ // Re-add the other axes one at a time, keeping only those that stay reachable.
210
+ const kept = { [axisKey]: option };
211
+ for (const axis of variationAxes(product)) {
212
+ const key = attributeKey(axis);
213
+ if (key === axisKey) continue;
214
+ const chosen = next[key];
215
+ if (!chosen) continue;
216
+ const candidate = { ...kept, [key]: chosen };
217
+ if (matchingVariations(product, variations, candidate).length) kept[key] = chosen;
218
+ }
219
+ return kept;
220
+ }
221
+
222
+ /**
223
+ * The initial selection for a freshly-opened product page: the merchant's
224
+ * `default_attributes`, plus any axis that has only one option. Options that
225
+ * lead nowhere are skipped, so the result is always reachable.
226
+ *
227
+ * @returns {Record<string, string>}
228
+ */
229
+ export function defaultSelection(product, variations) {
230
+ let selection = {};
231
+ const axes = axisOptions(product, variations);
232
+ const byKey = new Map(axes.map((a) => [a.key, a]));
233
+
234
+ for (const preset of product?.default_attributes ?? []) {
235
+ const key = attributeKey(preset);
236
+ if (!preset.option || !byKey.has(key)) continue;
237
+ if (!byKey.get(key).options.includes(preset.option)) continue;
238
+ selection = selectOption(product, variations, selection, key, preset.option);
239
+ }
240
+ for (const axis of axes) {
241
+ if (selection[axis.key] || axis.options.length !== 1) continue;
242
+ selection = selectOption(product, variations, selection, axis.key, axis.options[0]);
243
+ }
244
+ return selection;
245
+ }
246
+
247
+ /**
248
+ * Price range across the variations still reachable from `selection` (all of
249
+ * them by default). Use this for a variable product's headline price: the
250
+ * backend rolls stock up to the parent but **not** price, so `product.price`
251
+ * is not a variable product's price.
252
+ *
253
+ * @returns {{min: number, max: number, on_sale: boolean, count: number}|null} `null` when nothing is priced
254
+ */
255
+ export function priceRange(product, variations, selection = {}) {
256
+ const prices = [];
257
+ let onSale = false;
258
+ for (const v of matchingVariations(product, variations, selection)) {
259
+ const price = Number(v?.price ?? NaN);
260
+ if (!Number.isFinite(price)) continue;
261
+ prices.push(price);
262
+ if (v.on_sale) onSale = true;
263
+ }
264
+ if (!prices.length) return null;
265
+ return { min: Math.min(...prices), max: Math.max(...prices), on_sale: onSale, count: prices.length };
266
+ }
267
+
268
+ /**
269
+ * The fields to display for the current state, resolved variation-first with a
270
+ * fallback to the parent. `image` is the variation's own image when it has one
271
+ * — highlight it in the gallery rather than replacing the gallery.
272
+ *
273
+ * `image` is `{src, name, alt}`, or `null` when the product has no images at all
274
+ * — the one case needing a UI placeholder.
275
+ *
276
+ * @returns {{price: number|null, regular_price: number|null, sale_price: number|null,
277
+ * on_sale: boolean, sku: string, stock_status: string,
278
+ * stock_quantity: number|null, backorders: string, image: object|null,
279
+ * weight: number|null, dimensions: object|null, description: string,
280
+ * downloadable: boolean, virtual: boolean}}
281
+ */
282
+ export function displayFields(product, variation) {
283
+ const pick = (key, fallback = null) => {
284
+ const v = variation?.[key];
285
+ if (v !== undefined && v !== null && v !== "") return v;
286
+ const p = product?.[key];
287
+ return p !== undefined && p !== null && p !== "" ? p : fallback;
288
+ };
289
+ return {
290
+ price: variation ? (variation.price ?? null) : (product?.price ?? null),
291
+ regular_price: pick("regular_price"),
292
+ sale_price: variation ? (variation.sale_price ?? null) : (product?.sale_price ?? null),
293
+ on_sale: Boolean(variation ? variation.on_sale : product?.on_sale),
294
+ sku: pick("sku", ""),
295
+ stock_status: pick("stock_status", "instock"),
296
+ stock_quantity: variation
297
+ ? (variation.manage_stock === "yes" ? (variation.stock_quantity ?? null) : (product?.stock_quantity ?? null))
298
+ : (product?.stock_quantity ?? null),
299
+ backorders: pick("backorders", "no"),
300
+ image: variation?.image?.src ? variation.image : (product?.images?.[0] ?? null),
301
+ weight: pick("weight"),
302
+ dimensions: pick("dimensions"),
303
+ description: variation?.description || product?.description || "",
304
+ downloadable: Boolean(variation ? variation.downloadable : product?.downloadable),
305
+ virtual: Boolean(variation ? variation.virtual : product?.virtual),
306
+ };
307
+ }
308
+
309
+ /**
310
+ * One-call view model for a product page. Recompute it on every selection
311
+ * change and render straight from the result.
312
+ *
313
+ * ```js
314
+ * const { product, variations } = await getProduct(slug);
315
+ * let selection = defaultSelection(product, variations);
316
+ * let view = resolveSelection(product, variations, selection);
317
+ * // on a click: selection = selectOption(product, variations, selection, axisKey, option)
318
+ * ```
319
+ *
320
+ * Branches on `product.type`, not on `variations` (pass `[]` for a simple
321
+ * product). A `variable` product never falls back to the parent, so one with no
322
+ * usable variations yields empty `axes[].options`, `purchasable: false` and
323
+ * `addToCart: null` — render it unavailable and guard the "select a …" label.
324
+ *
325
+ * @returns {{
326
+ * isVariable: boolean, axes: Array<object>, selection: Record<string, string>,
327
+ * complete: boolean, variation: object|null, candidates: Array<object>,
328
+ * availability: Record<string, Record<string, string>>,
329
+ * priceRange: {min: number, max: number, on_sale: boolean, count: number}|null,
330
+ * display: object, purchasable: boolean, addToCart: {product_id: string, variation_id: string}|null,
331
+ * missingAxes: Array<{key: string, name: string}>
332
+ * }}
333
+ */
334
+ export function resolveSelection(product, variations, selection = {}) {
335
+ const isVariable = (product?.type ?? "simple") === "variable";
336
+ if (!isVariable) {
337
+ const display = displayFields(product, null);
338
+ return {
339
+ isVariable: false,
340
+ axes: [],
341
+ selection: {},
342
+ complete: true,
343
+ variation: null,
344
+ candidates: [],
345
+ availability: {},
346
+ priceRange: null,
347
+ display,
348
+ purchasable: PURCHASABLE_STOCK.has(display.stock_status),
349
+ addToCart: product?.id ? { product_id: product.id, variation_id: "" } : null,
350
+ missingAxes: [],
351
+ };
352
+ }
353
+
354
+ const axes = axisOptions(product, variations);
355
+ const variation = findVariation(product, variations, selection);
356
+ const display = displayFields(product, variation);
357
+ return {
358
+ isVariable: true,
359
+ axes,
360
+ selection: { ...selection },
361
+ complete: isSelectionComplete(product, selection),
362
+ variation,
363
+ candidates: matchingVariations(product, variations, selection),
364
+ availability: optionAvailability(product, variations, selection),
365
+ priceRange: priceRange(product, variations, selection),
366
+ display,
367
+ purchasable: Boolean(variation) && isPurchasable(variation),
368
+ addToCart: variation ? { product_id: product.id, variation_id: variation.id } : null,
369
+ missingAxes: axes
370
+ .filter((a) => !selection?.[a.key])
371
+ .map((a) => ({ key: a.key, name: a.name })),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Serialize a selection for a shareable URL, using attribute *names* (`?color=
377
+ * Red&size=M`) so links stay readable and survive an attribute-id change.
378
+ *
379
+ * @returns {Record<string, string>} query params, lowercase keys
380
+ */
381
+ export function selectionToParams(product, selection = {}) {
382
+ const params = {};
383
+ for (const axis of variationAxes(product)) {
384
+ const key = attributeKey(axis);
385
+ const option = selection?.[key];
386
+ if (option && axis.name) params[axis.name.toLowerCase()] = option;
387
+ }
388
+ return params;
389
+ }
390
+
391
+ /**
392
+ * Hydrate a selection from URL params written by `selectionToParams`. Matches
393
+ * axis names and options case-insensitively and drops anything unreachable.
394
+ *
395
+ * @param {object} product
396
+ * @param {Array<object>} variations
397
+ * @param {Record<string, string>|URLSearchParams} params
398
+ * @returns {Record<string, string>}
399
+ */
400
+ export function selectionFromParams(product, variations, params) {
401
+ const read = (name) =>
402
+ params instanceof URLSearchParams ? params.get(name) : params?.[name];
403
+ let selection = {};
404
+ for (const axis of axisOptions(product, variations)) {
405
+ const raw = read(axis.name?.toLowerCase?.() ?? "") ?? read(axis.name ?? "");
406
+ if (!raw) continue;
407
+ const option = axis.options.find((o) => o.toLowerCase() === String(raw).toLowerCase());
408
+ if (option) selection = selectOption(product, variations, selection, axis.key, option);
409
+ }
410
+ return selection;
411
+ }