@base44/app-plugin-commerce 0.1.20 → 0.2.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.
Files changed (73) hide show
  1. package/README.md +25 -22
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  11. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  12. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  13. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  14. package/base44/shared/commerce/card-payment.stripe.ts +198 -0
  15. package/base44/shared/commerce/card-payment.ts +1 -1
  16. package/base44/shared/commerce/payments.ts +2 -2
  17. package/base44/shared/commerce/scan.ts +1 -1
  18. package/base44/shared/commerce/sequence.ts +2 -2
  19. package/package.json +1 -1
  20. package/scripts/install.js +24 -14
  21. package/skills/commerce/SKILL.md +117 -51
  22. package/skills/commerce/docs/api-admin.md +89 -28
  23. package/skills/commerce/docs/api-storefront.md +113 -126
  24. package/skills/commerce/docs/entities.md +137 -0
  25. package/skills/commerce/install/01-install.md +101 -0
  26. package/skills/commerce/install/02-storefront.md +444 -0
  27. package/skills/commerce/install/03-data.md +162 -0
  28. package/skills/commerce/references/admin-product-form.md +10 -0
  29. package/skills/commerce/references/catalog-rendering.md +110 -0
  30. package/skills/commerce/references/emails.md +49 -12
  31. package/skills/commerce/references/guest-access-security.md +18 -5
  32. package/skills/commerce/references/online-payments.md +49 -149
  33. package/skills/commerce/references/operations.md +52 -0
  34. package/skills/commerce/references/reviews.md +31 -16
  35. package/skills/commerce/references/shipping-and-tax.md +110 -0
  36. package/skills/commerce/references/store-admin-agent.md +21 -0
  37. package/skills/commerce/references/store-settings.md +49 -0
  38. package/src/commerce/admin/README.md +6 -3
  39. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  40. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  41. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  42. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  43. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  44. package/src/commerce/storefront/index.js +74 -24
  45. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  46. package/src/commerce/storefront/useAddressForm.js +96 -0
  47. package/src/commerce/storefront/useCartLine.js +221 -0
  48. package/src/commerce/storefront/useCheckout.jsx +18 -6
  49. package/src/commerce/storefront/useOrderReturn.js +36 -10
  50. package/src/commerce/storefront/useProduct.js +295 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +22 -7
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/shipping-promos.js +2 -2
  62. package/src/commerce/utils/specs.js +26 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +58 -3
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -496
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -0,0 +1,242 @@
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
+ }
@@ -0,0 +1,204 @@
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
+ }
@@ -0,0 +1,109 @@
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
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The address form, as data. Framework-free; `useAddressForm` binds it to the
3
+ * checkout and the store's country list.
4
+ *
5
+ * Why a spec instead of markup: a hand-typed field table drifts from what the
6
+ * backend needs. Two failures come from that, and both are silent:
7
+ *
8
+ * - **`state` gets left out.** Shipping rates and taxes match on
9
+ * **country + state** (Shipping & Tax Location regions are
10
+ * continent/country/state — postcode and city are never matched), so a form
11
+ * without a state field mis-prices every US/CA/AU order without erroring.
12
+ * - **The country `<select>` renders before the store's country list arrives.**
13
+ * `useStoreInfo().countries` is `null` while store info loads; mapping over
14
+ * it crashes the checkout on a cold load. Here `options` is **always an
15
+ * array** — empty while loading, never null.
16
+ *
17
+ * Each field is `{ key, label, type, required, autoComplete, options?, colSpan }`
18
+ * — enough to render a real form (and to render it correctly on mobile, since
19
+ * `autoComplete` is what makes browser autofill work).
20
+ */
21
+
22
+ /**
23
+ * Build the field list for an address form.
24
+ *
25
+ * @param {{countries?: Array<{code: string, name: string, states?: Array<{code: string, name: string}>}>,
26
+ * country?: string, required?: string[], includeState?: boolean,
27
+ * includePhone?: boolean, includeCompany?: boolean, includeEmail?: boolean}} opts
28
+ * `country` is the currently-selected country — it decides whether `state`
29
+ * renders as a select (US/CA/AU and any country carrying `states`) or as
30
+ * free text.
31
+ * @returns {Array<{key: string, label: string, type: string, required: boolean,
32
+ * autoComplete: string, options: Array<{value: string, label: string}>,
33
+ * colSpan: number}>}
34
+ */
35
+ export function addressFieldSpec({
36
+ countries,
37
+ country,
38
+ required = [],
39
+ includeState = true,
40
+ includePhone = true,
41
+ includeCompany = false,
42
+ includeEmail = true,
43
+ } = {}) {
44
+ const list = Array.isArray(countries) ? countries : [];
45
+ const countryOptions = list.map((c) => ({ value: c.code, label: c.name }));
46
+ const states = list.find((c) => c.code === country)?.states ?? null;
47
+ const isRequired = (key) => required.includes(key);
48
+
49
+ const fields = [
50
+ { key: "first_name", label: "First name", type: "text", autoComplete: "given-name", colSpan: 1 },
51
+ { key: "last_name", label: "Last name", type: "text", autoComplete: "family-name", colSpan: 1 },
52
+ ...(includeEmail
53
+ ? [{ key: "email", label: "Email", type: "email", autoComplete: "email", colSpan: 2 }]
54
+ : []),
55
+ ...(includeCompany
56
+ ? [{ key: "company", label: "Company", type: "text", autoComplete: "organization", colSpan: 2 }]
57
+ : []),
58
+ { key: "address_1", label: "Address", type: "text", autoComplete: "address-line1", colSpan: 2 },
59
+ { key: "address_2", label: "Apartment, suite (optional)", type: "text", autoComplete: "address-line2", colSpan: 2 },
60
+ // Country before city/state: picking it re-shapes the state field and is
61
+ // what arms the shipping/tax recalculation.
62
+ { key: "country", label: "Country", type: "select", autoComplete: "country", options: countryOptions, colSpan: 1 },
63
+ { key: "city", label: "City", type: "text", autoComplete: "address-level2", colSpan: 1 },
64
+ ...(includeState
65
+ ? [{
66
+ key: "state",
67
+ label: states ? "State / Province" : "State / Region (optional)",
68
+ type: states ? "select" : "text",
69
+ autoComplete: "address-level1",
70
+ options: states ? states.map((s) => ({ value: s.code, label: s.name })) : [],
71
+ // A country with subdivisions prices on them, so the field is
72
+ // effectively required there even though `place-order` doesn't say so.
73
+ requiredOverride: Boolean(states),
74
+ colSpan: 1,
75
+ }]
76
+ : []),
77
+ { key: "postcode", label: "Postal code", type: "text", autoComplete: "postal-code", colSpan: 1 },
78
+ ...(includePhone
79
+ ? [{ key: "phone", label: "Phone (optional)", type: "tel", autoComplete: "tel", colSpan: 2 }]
80
+ : []),
81
+ ];
82
+
83
+ return fields.map((f) => ({
84
+ options: [],
85
+ ...f,
86
+ required: f.requiredOverride ?? isRequired(f.key),
87
+ requiredOverride: undefined,
88
+ }));
89
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Product images, normalized. Framework-free; `useProductGallery` adds the
3
+ * gallery's index state on top.
4
+ *
5
+ * Every stored image is an **object** — `{ src, name, alt }` — never a URL
6
+ * string, and a product can legitimately have none. Both facts have to survive
7
+ * into the markup: `<img src={product.images[0]}>` renders a broken image, and
8
+ * `{image?.src && <img …/>}` collapses the card to nothing instead of showing
9
+ * a placeholder. `productImages` hands back a clean array so a renderer can
10
+ * only get it right, and `hasImages === false` is the deliberate
11
+ * render-a-placeholder signal.
12
+ */
13
+ import { normalizeImage } from "./variants.js";
14
+
15
+ /**
16
+ * A product's renderable images, normalized and de-duplicated by `src`.
17
+ *
18
+ * @param {object} product
19
+ * @returns {Array<{src: string, name: string, alt: string}>} may be empty —
20
+ * that is the "render your placeholder" case, not an error
21
+ */
22
+ export function productImages(product) {
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const raw of product?.images ?? []) {
26
+ const img = normalizeImage(raw);
27
+ if (!img?.src || seen.has(img.src)) continue;
28
+ seen.add(img.src);
29
+ out.push(img);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Where a variation's image sits inside the product's gallery, so a selection
36
+ * change can **highlight** that image rather than replacing the gallery.
37
+ *
38
+ * @param {Array<{src: string}>} images from `productImages`
39
+ * @param {object|null} image a normalized image (e.g. `view.display.image`)
40
+ * @returns {number} index, or -1 when the variation's image isn't in the gallery
41
+ */
42
+ export function imageIndex(images, image) {
43
+ if (!image?.src) return -1;
44
+ return images.findIndex((i) => i.src === image.src);
45
+ }