@base44/app-plugin-commerce 0.2.7 → 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.
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +26 -12
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +5 -2
- package/skills/commerce/install/02-storefront.md +167 -275
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -1,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
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import { productImages } from "@/commerce/utils";
|
|
3
|
-
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
4
|
-
import { useCartUIOptional } from "./cartUI";
|
|
5
|
-
import { useAsyncData } from "./internal/useAsyncData";
|
|
6
|
-
import { useAddItem } from "./useProduct";
|
|
7
|
-
import { useProductPrice } from "./useProductPrice";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* useUpsell — one product offered beside another surface (a cart drawer's
|
|
11
|
-
* "add the care kit", a checkout's "complete the set", a cross-sell strip
|
|
12
|
-
* entry), with the lifecycle solved:
|
|
13
|
-
*
|
|
14
|
-
* const kit = useUpsell("care-kit"); // slug or { id }…
|
|
15
|
-
* const kit = useUpsell(p.crossSells[0]); // …or a row you already have
|
|
16
|
-
* {kit.show && (
|
|
17
|
-
* <aside className="…">
|
|
18
|
-
* {kit.image && <img src={kit.image.src} alt={kit.image.alt} className="…" />}
|
|
19
|
-
* {kit.product.name} {kit.price.label}
|
|
20
|
-
* <button type="button" onClick={kit.add} disabled={kit.adding}>Add</button>
|
|
21
|
-
* {kit.error && <p role="alert">{kit.error.message}</p>}
|
|
22
|
-
* </aside>
|
|
23
|
-
* )}
|
|
24
|
-
*
|
|
25
|
-
* `show` is the one flag to branch on: it is false while loading, on a fetch
|
|
26
|
-
* failure, when the product is out of stock, and **when it is already in the
|
|
27
|
-
* cart** — matched by product id, never by display name (a rename must not
|
|
28
|
-
* break the match). Passing a row you already hold (`product.upsells` /
|
|
29
|
-
* `product.crossSells` from `useProduct`, or a `useProductList` row) skips the
|
|
30
|
-
* fetch entirely — prefer that when the row is on hand.
|
|
31
|
-
*
|
|
32
|
-
* `add()` never throws; a product with options can't be one-click added
|
|
33
|
-
* (`needsSelection` — link to its page instead). With `<CartUIProvider>`
|
|
34
|
-
* mounted, a successful add opens the drawer.
|
|
35
|
-
*
|
|
36
|
-
* @param {string|{id: string}|object} ref slug, `{ id }`, or a product row
|
|
37
|
-
* @param {{quantity?: number}} [options]
|
|
38
|
-
* @returns {{show: boolean, inCart: boolean, needsSelection: boolean,
|
|
39
|
-
* product: object|null, image: {src: string, alt: string}|null,
|
|
40
|
-
* price: object, add: () => Promise<object>, adding: boolean,
|
|
41
|
-
* error: object|null}}
|
|
42
|
-
*/
|
|
43
|
-
export function useUpsell(ref, { quantity = 1 } = {}) {
|
|
44
|
-
const store = useStorefront();
|
|
45
|
-
const { cart } = useCart();
|
|
46
|
-
const cartUI = useCartUIOptional();
|
|
47
|
-
|
|
48
|
-
// A row (has id + name) is used as-is; a slug or { id } is fetched.
|
|
49
|
-
const isRow = Boolean(ref && typeof ref === "object" && ref.id && ref.name !== undefined);
|
|
50
|
-
const refKey = isRow ? `row:${ref.id}` : typeof ref === "string" ? ref : JSON.stringify(ref ?? null);
|
|
51
|
-
|
|
52
|
-
const { data, loading, error: fetchError } = useAsyncData(
|
|
53
|
-
() => (isRow || !ref ? Promise.resolve(null) : store.getProduct(ref)),
|
|
54
|
-
[store, refKey], // eslint-disable-line react-hooks/exhaustive-deps
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const product = isRow ? ref : (data?.product ?? null);
|
|
58
|
-
const needsSelection = isRow
|
|
59
|
-
? (ref.attributes?.length ?? 0) > 0
|
|
60
|
-
: (data?.variations?.length ?? 0) > 0;
|
|
61
|
-
|
|
62
|
-
const price = useProductPrice(product);
|
|
63
|
-
const { add: rawAdd, adding, error, reset } = useAddItem();
|
|
64
|
-
|
|
65
|
-
const add = useCallback(async () => {
|
|
66
|
-
if (!product) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
67
|
-
if (needsSelection) {
|
|
68
|
-
return { ok: false, error: { code: "variation_required", message: "Choose an option first." } };
|
|
69
|
-
}
|
|
70
|
-
const res = await rawAdd({ product_id: product.id }, quantity);
|
|
71
|
-
if (res.ok) cartUI?.onItemAdded?.();
|
|
72
|
-
return res;
|
|
73
|
-
}, [product, needsSelection, rawAdd, quantity, cartUI]);
|
|
74
|
-
|
|
75
|
-
const inCart = Boolean(product) && (cart?.items ?? []).some((i) => i.product_id === product.id);
|
|
76
|
-
const busy = !isRow && Boolean(ref) && loading;
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
show: !busy && !fetchError && Boolean(product) && !inCart && product?.stock_status !== "outofstock",
|
|
80
|
-
inCart,
|
|
81
|
-
needsSelection,
|
|
82
|
-
product,
|
|
83
|
-
image: productImages(product)[0] ?? null,
|
|
84
|
-
price,
|
|
85
|
-
add,
|
|
86
|
-
adding,
|
|
87
|
-
error,
|
|
88
|
-
reset,
|
|
89
|
-
};
|
|
90
|
-
}
|