@base44/app-plugin-commerce 0.1.19 → 0.2.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.
- package/README.md +25 -22
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/shared/commerce/card-payment.stripe.ts +178 -0
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +107 -51
- package/skills/commerce/docs/api-admin.md +89 -28
- package/skills/commerce/docs/api-storefront.md +113 -126
- package/skills/commerce/docs/entities.md +137 -0
- package/skills/commerce/install/01-install.md +101 -0
- package/skills/commerce/install/02-storefront.md +188 -0
- package/skills/commerce/install/03-data.md +162 -0
- package/skills/commerce/references/admin-product-form.md +10 -0
- package/skills/commerce/references/catalog-rendering.md +110 -0
- package/skills/commerce/references/emails.md +49 -12
- package/skills/commerce/references/guest-access-security.md +18 -5
- package/skills/commerce/references/online-payments.md +50 -149
- package/skills/commerce/references/operations.md +52 -0
- package/skills/commerce/references/reviews.md +31 -16
- package/skills/commerce/references/shipping-and-tax.md +110 -0
- package/skills/commerce/references/store-admin-agent.md +21 -0
- package/skills/commerce/references/store-settings.md +49 -0
- package/src/commerce/admin/README.md +2 -2
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
- package/src/commerce/storefront/blocks/index.js +44 -0
- package/src/commerce/storefront/index.js +59 -21
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/pickers.jsx +20 -5
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- package/src/commerce/storefront/useCheckout.jsx +38 -11
- package/src/commerce/storefront/useProduct.js +227 -0
- package/src/commerce/storefront/useProductGallery.js +74 -0
- package/src/commerce/storefront/useProductList.js +153 -0
- package/src/commerce/storefront/useProductPrice.js +58 -0
- package/src/commerce/storefront/useProductReviews.js +242 -0
- package/src/commerce/storefront/useStorefrontSeo.js +204 -0
- package/src/commerce/storefront/useTotalsLines.js +109 -0
- package/src/commerce/utils/address-spec.js +89 -0
- package/src/commerce/utils/images.js +45 -0
- package/src/commerce/utils/index.js +18 -6
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +10 -2
- package/skills/commerce/installation-guidelines.md +0 -93
- package/skills/commerce/post-installation.md +0 -495
- package/skills/commerce/references/limits-and-performance.md +0 -16
- package/skills/commerce/references/media-and-downloads.md +0 -4
- package/skills/commerce/references/product-render.md +0 -89
- package/skills/commerce/references/scheduled-work.md +0 -19
- package/skills/commerce/references/storefront-product-page.md +0 -83
- 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
|
+
}
|