@base44/app-plugin-commerce 0.2.7 → 0.3.2
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/README.md +4 -4
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +34 -17
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +6 -8
- package/skills/commerce/install/02-storefront.md +180 -278
- package/skills/commerce/install/03-data.md +13 -11
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/online-payments.md +10 -0
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Storefront SEO — titles, meta tags and product structured data, with no
|
|
5
|
-
* dependency (no react-helmet). A store whose every page shares one static
|
|
6
|
-
* `<title>` is invisible to search and unshareable on social; this is the
|
|
7
|
-
* cheapest possible fix, one line per page:
|
|
8
|
-
*
|
|
9
|
-
* useStorefrontSeo(productSeo(product, view, { storeName, currency }));
|
|
10
|
-
* useStorefrontSeo(collectionSeo({ title: "Gowns", products }));
|
|
11
|
-
* useStorefrontSeo(orderSeo(order)); // noindex — a receipt must not rank
|
|
12
|
-
*
|
|
13
|
-
* Everything it sets is restored on unmount, so navigating away can't leave a
|
|
14
|
-
* product's title on the home page.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** Upsert one <meta>, returning a restore function. */
|
|
18
|
-
function setMeta(attr, name, content) {
|
|
19
|
-
if (typeof document === "undefined") return () => {};
|
|
20
|
-
const selector = `meta[${attr}="${name}"]`;
|
|
21
|
-
let el = document.head.querySelector(selector);
|
|
22
|
-
const created = !el;
|
|
23
|
-
const previous = el?.getAttribute("content") ?? null;
|
|
24
|
-
if (!el) {
|
|
25
|
-
el = document.createElement("meta");
|
|
26
|
-
el.setAttribute(attr, name);
|
|
27
|
-
document.head.appendChild(el);
|
|
28
|
-
}
|
|
29
|
-
el.setAttribute("content", content ?? "");
|
|
30
|
-
return () => {
|
|
31
|
-
if (created) el.remove();
|
|
32
|
-
else if (previous != null) el.setAttribute("content", previous);
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function setLink(rel, href) {
|
|
37
|
-
if (typeof document === "undefined") return () => {};
|
|
38
|
-
let el = document.head.querySelector(`link[rel="${rel}"]`);
|
|
39
|
-
const created = !el;
|
|
40
|
-
const previous = el?.getAttribute("href") ?? null;
|
|
41
|
-
if (!el) {
|
|
42
|
-
el = document.createElement("link");
|
|
43
|
-
el.setAttribute("rel", rel);
|
|
44
|
-
document.head.appendChild(el);
|
|
45
|
-
}
|
|
46
|
-
el.setAttribute("href", href);
|
|
47
|
-
return () => {
|
|
48
|
-
if (created) el.remove();
|
|
49
|
-
else if (previous != null) el.setAttribute("href", previous);
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Apply page metadata. Pass the output of `productSeo`/`collectionSeo`/
|
|
55
|
-
* `orderSeo`, or your own object.
|
|
56
|
-
*
|
|
57
|
-
* @param {{title?: string, description?: string, image?: string,
|
|
58
|
-
* canonical?: string, noindex?: boolean, jsonLd?: object}} seo
|
|
59
|
-
*/
|
|
60
|
-
export function useStorefrontSeo(seo) {
|
|
61
|
-
const { title, description, image, canonical, noindex, jsonLd } = seo ?? {};
|
|
62
|
-
const jsonLdKey = jsonLd ? JSON.stringify(jsonLd) : null;
|
|
63
|
-
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (typeof document === "undefined") return undefined;
|
|
66
|
-
const undo = [];
|
|
67
|
-
|
|
68
|
-
if (title) {
|
|
69
|
-
const previous = document.title;
|
|
70
|
-
document.title = title;
|
|
71
|
-
undo.push(() => {
|
|
72
|
-
document.title = previous;
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
if (description) {
|
|
76
|
-
undo.push(setMeta("name", "description", description));
|
|
77
|
-
undo.push(setMeta("property", "og:description", description));
|
|
78
|
-
}
|
|
79
|
-
if (title) {
|
|
80
|
-
undo.push(setMeta("property", "og:title", title));
|
|
81
|
-
undo.push(setMeta("name", "twitter:title", title));
|
|
82
|
-
}
|
|
83
|
-
if (image) {
|
|
84
|
-
undo.push(setMeta("property", "og:image", image));
|
|
85
|
-
undo.push(setMeta("name", "twitter:card", "summary_large_image"));
|
|
86
|
-
undo.push(setMeta("name", "twitter:image", image));
|
|
87
|
-
}
|
|
88
|
-
if (canonical) undo.push(setLink("canonical", canonical));
|
|
89
|
-
if (noindex) undo.push(setMeta("name", "robots", "noindex,nofollow"));
|
|
90
|
-
|
|
91
|
-
if (jsonLdKey) {
|
|
92
|
-
const script = document.createElement("script");
|
|
93
|
-
script.type = "application/ld+json";
|
|
94
|
-
script.textContent = jsonLdKey;
|
|
95
|
-
document.head.appendChild(script);
|
|
96
|
-
undo.push(() => script.remove());
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return () => undo.forEach((fn) => fn());
|
|
100
|
-
}, [title, description, image, canonical, noindex, jsonLdKey]);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const stripHtml = (html) =>
|
|
104
|
-
String(html ?? "")
|
|
105
|
-
.replace(/<[^>]*>/g, " ")
|
|
106
|
-
.replace(/\s+/g, " ")
|
|
107
|
-
.trim();
|
|
108
|
-
|
|
109
|
-
const currentUrl = (url) =>
|
|
110
|
-
url ?? (typeof window !== "undefined" ? window.location.href.split("?")[0] : undefined);
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* SEO for a product page, including schema.org `Product` + `Offer` structured
|
|
114
|
-
* data (price, availability, rating, sku) — what makes a product eligible for
|
|
115
|
-
* rich results.
|
|
116
|
-
*
|
|
117
|
-
* @param {object} product
|
|
118
|
-
* @param {object|null} [view] a `resolveSelection` view — its resolved price
|
|
119
|
-
* and image are used when present
|
|
120
|
-
* @param {{storeName?: string, currency?: string, url?: string}} [opts]
|
|
121
|
-
*/
|
|
122
|
-
export function productSeo(product, view = null, { storeName, currency, url } = {}) {
|
|
123
|
-
if (!product) return {};
|
|
124
|
-
const display = view?.display ?? product;
|
|
125
|
-
const image = display?.image?.src ?? product.images?.[0]?.src;
|
|
126
|
-
const description =
|
|
127
|
-
stripHtml(product.short_description) || stripHtml(product.description).slice(0, 300);
|
|
128
|
-
const price = display?.price ?? product.price;
|
|
129
|
-
const inStock = ["instock", "onbackorder"].includes(display?.stock_status ?? product.stock_status);
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
title: storeName ? `${product.name} — ${storeName}` : product.name,
|
|
133
|
-
description,
|
|
134
|
-
image,
|
|
135
|
-
canonical: currentUrl(url),
|
|
136
|
-
jsonLd: {
|
|
137
|
-
"@context": "https://schema.org",
|
|
138
|
-
"@type": "Product",
|
|
139
|
-
name: product.name,
|
|
140
|
-
description,
|
|
141
|
-
...(image ? { image: [image] } : {}),
|
|
142
|
-
...(display?.sku || product.sku ? { sku: display?.sku || product.sku } : {}),
|
|
143
|
-
...(product.rating_count
|
|
144
|
-
? {
|
|
145
|
-
aggregateRating: {
|
|
146
|
-
"@type": "AggregateRating",
|
|
147
|
-
ratingValue: product.average_rating,
|
|
148
|
-
reviewCount: product.rating_count,
|
|
149
|
-
},
|
|
150
|
-
}
|
|
151
|
-
: {}),
|
|
152
|
-
...(price != null
|
|
153
|
-
? {
|
|
154
|
-
offers: {
|
|
155
|
-
"@type": "Offer",
|
|
156
|
-
price,
|
|
157
|
-
...(currency ? { priceCurrency: currency } : {}),
|
|
158
|
-
availability: `https://schema.org/${inStock ? "InStock" : "OutOfStock"}`,
|
|
159
|
-
...(currentUrl(url) ? { url: currentUrl(url) } : {}),
|
|
160
|
-
},
|
|
161
|
-
}
|
|
162
|
-
: {}),
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* SEO for a catalog/collection page, with an `ItemList` of the products shown.
|
|
169
|
-
*
|
|
170
|
-
* @param {{title: string, description?: string, products?: Array<object>,
|
|
171
|
-
* storeName?: string, url?: string}} opts
|
|
172
|
-
*/
|
|
173
|
-
export function collectionSeo({ title, description, products = [], storeName, url } = {}) {
|
|
174
|
-
return {
|
|
175
|
-
title: storeName && title ? `${title} — ${storeName}` : title,
|
|
176
|
-
description,
|
|
177
|
-
image: products[0]?.images?.[0]?.src,
|
|
178
|
-
canonical: currentUrl(url),
|
|
179
|
-
jsonLd: products.length
|
|
180
|
-
? {
|
|
181
|
-
"@context": "https://schema.org",
|
|
182
|
-
"@type": "ItemList",
|
|
183
|
-
itemListElement: products.slice(0, 24).map((p, i) => ({
|
|
184
|
-
"@type": "ListItem",
|
|
185
|
-
position: i + 1,
|
|
186
|
-
name: p.name,
|
|
187
|
-
})),
|
|
188
|
-
}
|
|
189
|
-
: undefined,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* SEO for `/order-received` — and for `/checkout`: both must be **noindex**.
|
|
195
|
-
* A receipt page carrying an order key has no business in a search index.
|
|
196
|
-
*
|
|
197
|
-
* @param {object} [order]
|
|
198
|
-
*/
|
|
199
|
-
export function orderSeo(order) {
|
|
200
|
-
return {
|
|
201
|
-
title: order?.order_number ? `Order ${order.order_number}` : "Order",
|
|
202
|
-
noindex: true,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { cartTotalsLines, orderTotalsLines } from "@/commerce/utils";
|
|
3
|
-
import { useCart, useFormatMoney } from "./StorefrontProvider";
|
|
4
|
-
import { useCheckoutContextOptional } from "./useCheckout";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* useTotalsLines — the summary lines for the shared cart, or for a placed order.
|
|
8
|
-
*
|
|
9
|
-
* const lines = useTotalsLines(); // the cart (bag page, checkout summary)
|
|
10
|
-
* const lines = useTotalsLines(order); // a placed order (order-received)
|
|
11
|
-
*
|
|
12
|
-
* {lines.filter(l => !l.hidden).map(l => (
|
|
13
|
-
* <p key={l.key} className={l.emphasis ? "font-medium" : ""}>
|
|
14
|
-
* <span>{l.label}</span><span>{l.formatted}</span>
|
|
15
|
-
* </p>
|
|
16
|
-
* ))}
|
|
17
|
-
*
|
|
18
|
-
* Always five keys — `subtotal` `discount` `shipping` `tax` `total` — with
|
|
19
|
-
* `hidden` set on a zero discount or tax. Rendering the array is what keeps a
|
|
20
|
-
* summary adding up once the store issues its first coupon or charges tax; a
|
|
21
|
-
* hand-written block reliably omits exactly those two rows. It also absorbs the
|
|
22
|
-
* cart-vs-order shape difference: a cart's totals are nested under
|
|
23
|
-
* `cart.totals`, an order's are flat on the order.
|
|
24
|
-
*
|
|
25
|
-
* @param {object} [order] pass a placed order to project it instead of the cart
|
|
26
|
-
*/
|
|
27
|
-
export function useTotalsLines(order) {
|
|
28
|
-
const formatMoney = useFormatMoney();
|
|
29
|
-
const { cart } = useCart();
|
|
30
|
-
return useMemo(
|
|
31
|
-
() =>
|
|
32
|
-
order
|
|
33
|
-
? orderTotalsLines(order, { formatMoney })
|
|
34
|
-
: cart
|
|
35
|
-
? cartTotalsLines(cart, { formatMoney })
|
|
36
|
-
: [],
|
|
37
|
-
[order, cart, formatMoney],
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const BLOCKER_MESSAGES = {
|
|
42
|
-
cart_loading: "Loading your bag…",
|
|
43
|
-
empty_cart: "Your bag is empty.",
|
|
44
|
-
billing_incomplete: "Complete your details to continue.",
|
|
45
|
-
shipping_address_incomplete: "Enter the delivery address.",
|
|
46
|
-
shipping_recalculating: "Updating delivery options…",
|
|
47
|
-
shipping_address_required: "Enter your address to see delivery options.",
|
|
48
|
-
shipping_method_required: "Choose a delivery option.",
|
|
49
|
-
shipping_not_available: "We don't deliver to that address yet.",
|
|
50
|
-
payment_method_required: "Choose how you'd like to pay.",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const BLOCKER_FIELDS = {
|
|
54
|
-
billing_incomplete: "billing",
|
|
55
|
-
shipping_address_incomplete: "shipping",
|
|
56
|
-
shipping_address_required: "country",
|
|
57
|
-
shipping_not_available: "country",
|
|
58
|
-
shipping_method_required: "shipping_method",
|
|
59
|
-
payment_method_required: "payment_method",
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A blocker code turned into customer-facing copy.
|
|
64
|
-
*
|
|
65
|
-
* @param {string} code one of `useCheckout().blockers`
|
|
66
|
-
* @param {{missingBillingFields?: string[], labels?: Record<string, string>}} [opts]
|
|
67
|
-
* pass `missingBillingFields` and an incomplete-billing message names the
|
|
68
|
-
* fields instead of saying "complete your details".
|
|
69
|
-
* @returns {string}
|
|
70
|
-
*/
|
|
71
|
-
export function blockerMessage(code, { missingBillingFields, labels } = {}) {
|
|
72
|
-
if (labels?.[code]) return labels[code];
|
|
73
|
-
if (code === "billing_incomplete" && missingBillingFields?.length) {
|
|
74
|
-
const names = missingBillingFields.map((f) => f.replace(/_/g, " ")).join(", ");
|
|
75
|
-
return `Still needed: ${names}.`;
|
|
76
|
-
}
|
|
77
|
-
return BLOCKER_MESSAGES[code] ?? "Something is still missing.";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* useCheckoutBlockers — why the place-order button is disabled, in words.
|
|
82
|
-
*
|
|
83
|
-
* const blockers = useCheckoutBlockers();
|
|
84
|
-
* <button disabled={!canPlaceOrder}>Place order</button>
|
|
85
|
-
* {blockers.map(b => <p key={b.code}>{b.message}</p>)}
|
|
86
|
-
*
|
|
87
|
-
* `useCheckout` already names every blocker; the reason they went unrendered in
|
|
88
|
-
* practice is that a code like `shipping_method_required` needs copy invented
|
|
89
|
-
* for it. This supplies the copy (overridable) plus the `field` each one points
|
|
90
|
-
* at, so a page can anchor the hint next to the input that fixes it — a
|
|
91
|
-
* disabled button with no explanation is the most common checkout dead end.
|
|
92
|
-
*
|
|
93
|
-
* Needs a `<CheckoutProvider>` above it; returns `[]` without one.
|
|
94
|
-
*/
|
|
95
|
-
export function useCheckoutBlockers({ labels } = {}) {
|
|
96
|
-
const checkout = useCheckoutContextOptional();
|
|
97
|
-
const blockers = checkout?.blockers ?? [];
|
|
98
|
-
const missing = checkout?.missingBillingFields;
|
|
99
|
-
return useMemo(
|
|
100
|
-
() =>
|
|
101
|
-
blockers.map((code) => ({
|
|
102
|
-
code,
|
|
103
|
-
message: blockerMessage(code, { missingBillingFields: missing, labels }),
|
|
104
|
-
field: BLOCKER_FIELDS[code] ?? null,
|
|
105
|
-
})),
|
|
106
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
-
[JSON.stringify(blockers), JSON.stringify(missing ?? []), labels],
|
|
108
|
-
);
|
|
109
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import { productImages } from "@/commerce/utils";
|
|
3
|
-
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
4
|
-
import { useCartUIOptional } from "./cartUI";
|
|
5
|
-
import { useAsyncData } from "./internal/useAsyncData";
|
|
6
|
-
import { useAddItem } from "./useProduct";
|
|
7
|
-
import { useProductPrice } from "./useProductPrice";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* useUpsell — one product offered beside another surface (a cart drawer's
|
|
11
|
-
* "add the care kit", a checkout's "complete the set", a cross-sell strip
|
|
12
|
-
* entry), with the lifecycle solved:
|
|
13
|
-
*
|
|
14
|
-
* const kit = useUpsell("care-kit"); // slug or { id }…
|
|
15
|
-
* const kit = useUpsell(p.crossSells[0]); // …or a row you already have
|
|
16
|
-
* {kit.show && (
|
|
17
|
-
* <aside className="…">
|
|
18
|
-
* {kit.image && <img src={kit.image.src} alt={kit.image.alt} className="…" />}
|
|
19
|
-
* {kit.product.name} {kit.price.label}
|
|
20
|
-
* <button type="button" onClick={kit.add} disabled={kit.adding}>Add</button>
|
|
21
|
-
* {kit.error && <p role="alert">{kit.error.message}</p>}
|
|
22
|
-
* </aside>
|
|
23
|
-
* )}
|
|
24
|
-
*
|
|
25
|
-
* `show` is the one flag to branch on: it is false while loading, on a fetch
|
|
26
|
-
* failure, when the product is out of stock, and **when it is already in the
|
|
27
|
-
* cart** — matched by product id, never by display name (a rename must not
|
|
28
|
-
* break the match). Passing a row you already hold (`product.upsells` /
|
|
29
|
-
* `product.crossSells` from `useProduct`, or a `useProductList` row) skips the
|
|
30
|
-
* fetch entirely — prefer that when the row is on hand.
|
|
31
|
-
*
|
|
32
|
-
* `add()` never throws; a product with options can't be one-click added
|
|
33
|
-
* (`needsSelection` — link to its page instead). With `<CartUIProvider>`
|
|
34
|
-
* mounted, a successful add opens the drawer.
|
|
35
|
-
*
|
|
36
|
-
* @param {string|{id: string}|object} ref slug, `{ id }`, or a product row
|
|
37
|
-
* @param {{quantity?: number}} [options]
|
|
38
|
-
* @returns {{show: boolean, inCart: boolean, needsSelection: boolean,
|
|
39
|
-
* product: object|null, image: {src: string, alt: string}|null,
|
|
40
|
-
* price: object, add: () => Promise<object>, adding: boolean,
|
|
41
|
-
* error: object|null}}
|
|
42
|
-
*/
|
|
43
|
-
export function useUpsell(ref, { quantity = 1 } = {}) {
|
|
44
|
-
const store = useStorefront();
|
|
45
|
-
const { cart } = useCart();
|
|
46
|
-
const cartUI = useCartUIOptional();
|
|
47
|
-
|
|
48
|
-
// A row (has id + name) is used as-is; a slug or { id } is fetched.
|
|
49
|
-
const isRow = Boolean(ref && typeof ref === "object" && ref.id && ref.name !== undefined);
|
|
50
|
-
const refKey = isRow ? `row:${ref.id}` : typeof ref === "string" ? ref : JSON.stringify(ref ?? null);
|
|
51
|
-
|
|
52
|
-
const { data, loading, error: fetchError } = useAsyncData(
|
|
53
|
-
() => (isRow || !ref ? Promise.resolve(null) : store.getProduct(ref)),
|
|
54
|
-
[store, refKey], // eslint-disable-line react-hooks/exhaustive-deps
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const product = isRow ? ref : (data?.product ?? null);
|
|
58
|
-
const needsSelection = isRow
|
|
59
|
-
? (ref.attributes?.length ?? 0) > 0
|
|
60
|
-
: (data?.variations?.length ?? 0) > 0;
|
|
61
|
-
|
|
62
|
-
const price = useProductPrice(product);
|
|
63
|
-
const { add: rawAdd, adding, error, reset } = useAddItem();
|
|
64
|
-
|
|
65
|
-
const add = useCallback(async () => {
|
|
66
|
-
if (!product) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
67
|
-
if (needsSelection) {
|
|
68
|
-
return { ok: false, error: { code: "variation_required", message: "Choose an option first." } };
|
|
69
|
-
}
|
|
70
|
-
const res = await rawAdd({ product_id: product.id }, quantity);
|
|
71
|
-
if (res.ok) cartUI?.onItemAdded?.();
|
|
72
|
-
return res;
|
|
73
|
-
}, [product, needsSelection, rawAdd, quantity, cartUI]);
|
|
74
|
-
|
|
75
|
-
const inCart = Boolean(product) && (cart?.items ?? []).some((i) => i.product_id === product.id);
|
|
76
|
-
const busy = !isRow && Boolean(ref) && loading;
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
show: !busy && !fetchError && Boolean(product) && !inCart && product?.stock_status !== "outofstock",
|
|
80
|
-
inCart,
|
|
81
|
-
needsSelection,
|
|
82
|
-
product,
|
|
83
|
-
image: productImages(product)[0] ?? null,
|
|
84
|
-
price,
|
|
85
|
-
add,
|
|
86
|
-
adding,
|
|
87
|
-
error,
|
|
88
|
-
reset,
|
|
89
|
-
};
|
|
90
|
-
}
|