@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- 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
|
+
}
|