@base44/app-plugin-commerce 0.2.6 → 0.3.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 (36) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -2
  3. package/scripts/install.js +15 -0
  4. package/skills/commerce/SKILL.md +31 -13
  5. package/skills/commerce/docs/api-admin.md +1 -1
  6. package/skills/commerce/docs/api-storefront.md +12 -12
  7. package/skills/commerce/install/01-install.md +5 -2
  8. package/skills/commerce/install/02-storefront.md +179 -220
  9. package/skills/commerce/install/03-data.md +1 -1
  10. package/skills/commerce/references/catalog-rendering.md +37 -43
  11. package/skills/commerce/references/reviews.md +21 -14
  12. package/skills/commerce/references/store-settings.md +1 -1
  13. package/skills/commerce/references/storefront-verification.md +21 -15
  14. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  15. package/src/commerce/storefront/cartUI.jsx +26 -117
  16. package/src/commerce/storefront/index.js +61 -98
  17. package/src/commerce/storefront/pickers.jsx +53 -81
  18. package/src/commerce/storefront/useCartLine.js +23 -130
  19. package/src/commerce/storefront/useCheckout.jsx +50 -43
  20. package/src/commerce/storefront/useOrderReturn.js +17 -7
  21. package/src/commerce/storefront/useProduct.js +54 -119
  22. package/src/commerce/storefront/useProductList.js +15 -28
  23. package/src/commerce/utils/address-spec.js +1 -1
  24. package/src/commerce/utils/images.js +1 -1
  25. package/src/commerce/utils/index.js +9 -9
  26. package/src/commerce/utils/price.js +2 -1
  27. package/src/commerce/utils/specs.js +41 -91
  28. package/src/commerce/utils/totals.js +7 -4
  29. package/src/commerce/storefront/useAddressForm.js +0 -175
  30. package/src/commerce/storefront/usePlaceOrder.js +0 -55
  31. package/src/commerce/storefront/useProductGallery.js +0 -78
  32. package/src/commerce/storefront/useProductPrice.js +0 -58
  33. package/src/commerce/storefront/useProductReviews.js +0 -242
  34. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  35. package/src/commerce/storefront/useTotalsLines.js +0 -109
  36. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -1,58 +0,0 @@
1
- import { useCallback, useMemo } from "react";
2
- import { productPrice } from "@/commerce/utils";
3
- import { useFormatMoney, useStoreInfo } from "./StorefrontProvider";
4
-
5
- /**
6
- * Money formatting in the store's currency, with the pieces a custom price UI
7
- * needs (parity with the admin's `useMoney`).
8
- *
9
- * @returns {{format: (n: number) => string, formatRange: (a: number, b: number) => string,
10
- * code: string|null, symbol: string, decimals: number}}
11
- */
12
- export function useMoney() {
13
- const format = useFormatMoney();
14
- const { settings } = useStoreInfo();
15
- const code = settings?.currency ?? null;
16
- const formatRange = useCallback((min, max) => `${format(min)} – ${format(max)}`, [format]);
17
- const symbol = useMemo(() => {
18
- if (!code) return "";
19
- try {
20
- // The currency part of a formatted zero — locale-correct, no symbol table.
21
- return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
22
- .formatToParts(0)
23
- .find((p) => p.type === "currency")?.value ?? code;
24
- } catch {
25
- return code;
26
- }
27
- }, [code]);
28
- const decimals = useMemo(() => {
29
- if (!code) return 2;
30
- try {
31
- return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
32
- .resolvedOptions().maximumFractionDigits ?? 2;
33
- } catch {
34
- return 2;
35
- }
36
- }, [code]);
37
-
38
- return { format, formatRange, code, symbol, decimals };
39
- }
40
-
41
- /**
42
- * The rendered price of a product row **or** a resolved selection view, in the
43
- * store's currency:
44
- *
45
- * const price = useProductPrice(row); // a card: "From €19.99"
46
- * const price = useProductPrice(view); // a product page: "€19.99 – €23.99"
47
- * // until the selection resolves
48
- * <span>{price.label}</span>
49
- * {price.compareAtLabel && <s>{price.compareAtLabel}</s>}
50
- *
51
- * One call for both views is the point — the from-price rule cannot be honoured
52
- * on the grid and forgotten on the product page. See `productPrice` for the
53
- * rules themselves.
54
- */
55
- export function useProductPrice(rowOrView) {
56
- const formatMoney = useFormatMoney();
57
- return useMemo(() => productPrice(rowOrView, { formatMoney }), [rowOrView, formatMoney]);
58
- }
@@ -1,242 +0,0 @@
1
- import { useCallback, useMemo, useState } from "react";
2
- import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
- import { useStorefront } from "./StorefrontProvider";
4
- import { useAsyncData } from "./internal/useAsyncData";
5
-
6
- const EMPTY_FORM = Object.freeze({ reviewer: "", email: "", review: "", rating: 5 });
7
-
8
- /**
9
- * useProductReviews — the review list AND the submit form, including the store
10
- * policy, in one hook.
11
- *
12
- * const r = useProductReviews(product, { policy: "open" });
13
- * // list: r.items, r.averageRating, r.ratingCount, r.hasNext, r.loadMore()
14
- * // form: r.form, r.setField("review", v), r.fieldErrors, r.valid, r.submit()
15
- * // after: r.submitted && <p>{r.message}</p>
16
- *
17
- * The backend ships complete (public submission by email, moderation,
18
- * auto-approve); everything that used to be hand-rolled around it lives here:
19
- *
20
- * - **The confirmation copy comes from the response.** `submit()` returns the
21
- * server's `status` — `"approved"` when the store has `auto_approve_reviews`
22
- * on, `"hold"` otherwise — and `message` follows it. A hardcoded "awaiting
23
- * approval" lies to half of all stores.
24
- * - **Errors are field-level.** The three server codes (`email_required`,
25
- * `review_incomplete`, `invalid_rating`) land in `fieldErrors` rather than
26
- * rejecting into nothing, and `valid` gates the round trip.
27
- * - **Submitting refreshes the list**, so an auto-approved review appears
28
- * instead of being invisible until the next page load.
29
- * - **`requiresEmail` is false for a signed-in visitor** — the session's email
30
- * always wins server-side, so asking for it again is a field that does
31
- * nothing.
32
- *
33
- * `policy` replaces the prose patterns a storefront used to implement by hand:
34
- * `"open"` (anyone with an email — the server's own default), `"login"` (only a
35
- * signed-in visitor; `canReview` false with `reviewBlockedReason:
36
- * "login_required"`), `"verified_buyers"` (only someone whose email has a
37
- * completed order for this product — `"not_a_buyer"`). Policies are UI-side by
38
- * design: the server accepts any valid email, so a stricter rule is exactly
39
- * this gate.
40
- *
41
- * @param {object} product the product being reviewed (needs `id`, and `slug` to page)
42
- * @param {{perPage?: number, policy?: "open"|"login"|"verified_buyers",
43
- * requireRating?: boolean, refreshOnSubmit?: boolean, user?: object|null,
44
- * initialReviews?: object}} [options]
45
- * `user` is your app's current user (Base44 auth) — pass it for the `login`
46
- * and `verified_buyers` policies and to drop the email field.
47
- */
48
- export function useProductReviews(product, options = {}) {
49
- const {
50
- perPage = 10,
51
- policy = "open",
52
- requireRating = false,
53
- refreshOnSubmit = true,
54
- user = null,
55
- initialReviews = null,
56
- } = options;
57
-
58
- const store = useStorefront();
59
- const [page, setPage] = useState(1);
60
- const [appended, setAppended] = useState(null);
61
- const ref = product?.slug ? product.slug : product?.id ? { id: product.id } : null;
62
-
63
- const { data, loading, refreshing, error, reload } = useAsyncData(
64
- () => store.getProductReviews(ref, { page, per_page: perPage }),
65
- [store, product?.id, page, perPage],
66
- { enabled: Boolean(ref), initialData: page === 1 ? initialReviews : null },
67
- );
68
-
69
- const items = useMemo(() => {
70
- const rows = data?.items ?? [];
71
- if (!appended) return rows;
72
- const byId = new Map();
73
- for (const r of [...appended, ...rows]) byId.set(r.id ?? `${r.reviewer}-${r.date_created}`, r);
74
- return [...byId.values()];
75
- }, [data, appended]);
76
-
77
- const hasNext = Boolean(data?.has_next);
78
- const next = useCallback(() => {
79
- if (hasNext) setPage((p) => p + 1);
80
- }, [hasNext]);
81
- const prev = useCallback(() => setPage((p) => Math.max(1, p - 1)), []);
82
- const loadMore = useCallback(() => {
83
- if (!hasNext) return;
84
- setAppended(items);
85
- setPage((p) => p + 1);
86
- }, [hasNext, items]);
87
-
88
- // ── the form ───────────────────────────────────────────────────────────────
89
- const [form, setForm] = useState(() => ({
90
- ...EMPTY_FORM,
91
- reviewer: user?.full_name ?? "",
92
- email: user?.email ?? "",
93
- }));
94
- const [submitting, setSubmitting] = useState(false);
95
- const [submitted, setSubmitted] = useState(false);
96
- const [submittedStatus, setSubmittedStatus] = useState(null);
97
- const [serverError, setServerError] = useState(null);
98
-
99
- const setField = useCallback((name, value) => {
100
- setServerError(null);
101
- setForm((f) => ({ ...f, [name]: value }));
102
- }, []);
103
- const reset = useCallback(() => {
104
- setForm({ ...EMPTY_FORM, reviewer: user?.full_name ?? "", email: user?.email ?? "" });
105
- setSubmitted(false);
106
- setSubmittedStatus(null);
107
- setServerError(null);
108
- }, [user?.full_name, user?.email]);
109
-
110
- // A signed-in visitor's email is taken from the session server-side.
111
- const requiresEmail = !user?.email;
112
-
113
- const fieldErrors = useMemo(() => {
114
- const errs = {};
115
- if (requiresEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(form.email ?? "").trim())) {
116
- errs.email = "A valid email address is required.";
117
- }
118
- if (!String(form.review ?? "").trim()) errs.review = "Write a few words about the product.";
119
- const rating = Number(form.rating);
120
- if (requireRating && !(rating >= 1 && rating <= 5)) errs.rating = "Choose a rating.";
121
- else if (form.rating != null && (rating < 0 || rating > 5)) errs.rating = "Rating must be 0–5.";
122
- if (serverError) errs[serverErrorField(serverError.code)] = serverError.message;
123
- return errs;
124
- }, [form, requiresEmail, requireRating, serverError]);
125
-
126
- // ── policy ─────────────────────────────────────────────────────────────────
127
- // "Has this visitor bought it?" can only be answered from their own orders —
128
- // public reviews carry no email (by design), so the answer comes from
129
- // `my-orders`. Fetched only for the policy that needs it.
130
- const needsPurchaseCheck = policy === "verified_buyers" && Boolean(user) && Boolean(product?.id);
131
- const { data: myOrders, loading: purchaseLoading } = useAsyncData(
132
- () => store.inv("commerce/storefront-account", { action: "my-orders", per_page: 50 }),
133
- [store, user?.email, product?.id],
134
- { enabled: needsPurchaseCheck },
135
- );
136
- const purchased = useMemo(() => {
137
- if (!needsPurchaseCheck) return false;
138
- return (myOrders?.orders ?? []).some(
139
- (o) =>
140
- ["completed", "processing"].includes(o.status) &&
141
- (o.line_items ?? []).some((li) => li.product_id === product.id),
142
- );
143
- }, [needsPurchaseCheck, myOrders, product?.id]);
144
-
145
- let reviewBlockedReason = null;
146
- if ((policy === "login" || policy === "verified_buyers") && !user) {
147
- reviewBlockedReason = "login_required";
148
- } else if (policy === "verified_buyers" && !purchased && !purchaseLoading) {
149
- reviewBlockedReason = "not_a_buyer";
150
- }
151
- const canReview = !reviewBlockedReason && !purchaseLoading && !submitted;
152
-
153
- const valid = canReview && Object.keys(fieldErrors).length === 0;
154
-
155
- const submit = useCallback(async () => {
156
- if (submitting) return { ok: false, error: { code: "submitting", message: "Already submitting." } };
157
- if (reviewBlockedReason) {
158
- return { ok: false, error: { code: reviewBlockedReason, message: blockedMessage(reviewBlockedReason) } };
159
- }
160
- if (Object.keys(fieldErrors).length) {
161
- return { ok: false, error: { code: "invalid_form", message: "Please complete the form." } };
162
- }
163
- setSubmitting(true);
164
- setServerError(null);
165
- try {
166
- const res = await store.submitReview({
167
- product_id: product?.id,
168
- review: String(form.review).trim(),
169
- rating: form.rating == null ? undefined : Number(form.rating),
170
- reviewer: String(form.reviewer ?? "").trim() || undefined,
171
- email: requiresEmail ? String(form.email).trim() : undefined,
172
- });
173
- setSubmitted(true);
174
- setSubmittedStatus(res.status);
175
- // An auto-approved review is live immediately — show it.
176
- if (refreshOnSubmit && res.status === "approved") {
177
- setAppended(null);
178
- setPage(1);
179
- reload();
180
- }
181
- return { ok: true, status: res.status, verified: res.verified };
182
- } catch (e) {
183
- const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
184
- setServerError(err);
185
- return { ok: false, error: err };
186
- } finally {
187
- setSubmitting(false);
188
- }
189
- }, [submitting, reviewBlockedReason, fieldErrors, store, product?.id, form, requiresEmail, refreshOnSubmit, reload]);
190
-
191
- const message = submitted
192
- ? submittedStatus === "approved"
193
- ? "Thanks — your review is published."
194
- : "Thanks — your review has been submitted for approval."
195
- : null;
196
-
197
- return {
198
- // list
199
- items,
200
- page,
201
- perPage: data?.per_page ?? perPage,
202
- hasNext,
203
- next,
204
- prev,
205
- loadMore,
206
- averageRating: data?.average_rating ?? product?.average_rating ?? 0,
207
- ratingCount: data?.rating_count ?? product?.rating_count ?? 0,
208
- loading,
209
- refreshing,
210
- error,
211
- reload,
212
- // submission
213
- form,
214
- setField,
215
- reset,
216
- fieldErrors,
217
- valid,
218
- submit,
219
- submitting,
220
- submitted,
221
- submittedStatus,
222
- message,
223
- // policy
224
- canReview,
225
- reviewBlockedReason,
226
- requiresEmail,
227
- };
228
- }
229
-
230
- /** Server error code → the form field it belongs on. */
231
- function serverErrorField(code) {
232
- if (code === "email_required") return "email";
233
- if (code === "invalid_rating") return "rating";
234
- if (code === "review_incomplete") return "review";
235
- return "form";
236
- }
237
-
238
- function blockedMessage(reason) {
239
- if (reason === "login_required") return "Please sign in to write a review.";
240
- if (reason === "not_a_buyer") return "Only verified buyers can review this product.";
241
- return "Reviews are closed.";
242
- }
@@ -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
- }