@base44/app-plugin-commerce 0.2.7 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +34 -17
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +6 -8
- package/skills/commerce/install/02-storefront.md +180 -278
- package/skills/commerce/install/03-data.md +13 -11
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/online-payments.md +10 -0
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from "react";
|
|
2
|
-
import { addressFieldSpec } from "@/commerce/utils";
|
|
3
|
-
import { useStoreInfo } from "./StorefrontProvider";
|
|
4
|
-
import { useCheckoutContext } from "./useCheckout";
|
|
5
|
-
import { REQUIRED_BILLING_FIELDS } from "./address";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The store's country list, **always as an array**.
|
|
9
|
-
*
|
|
10
|
-
* const { options, loading } = useCountries();
|
|
11
|
-
* <select>{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
|
12
|
-
*
|
|
13
|
-
* `useStoreInfo().countries` is `null` until store info resolves, so mapping it
|
|
14
|
-
* directly white-screens the checkout on a cold load — the most severe defect
|
|
15
|
-
* observed in a generated storefront. Here the list is empty-then-full, never
|
|
16
|
-
* null, and `loading` says which.
|
|
17
|
-
*/
|
|
18
|
-
export function useCountries() {
|
|
19
|
-
const { countries, loading, error } = useStoreInfo();
|
|
20
|
-
const list = Array.isArray(countries) ? countries : [];
|
|
21
|
-
const options = useMemo(() => list.map((c) => ({ value: c.code, label: c.name })), [countries]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
22
|
-
return { countries: list, options, loading, error };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Read the new value out of whatever a field's `set` was handed. All three
|
|
27
|
-
* forms an onChange is plausibly written as work, so a field setter can't be
|
|
28
|
-
* called "wrong":
|
|
29
|
-
*
|
|
30
|
-
* f.set(e.target.value) // the value
|
|
31
|
-
* f.set(e) // the change event (onChange={f.set})
|
|
32
|
-
* f.set(f.key, e.target.value) // key + value, mirroring the top-level set()
|
|
33
|
-
*/
|
|
34
|
-
function newValue(args) {
|
|
35
|
-
if (args.length >= 2) return args[1];
|
|
36
|
-
const first = args[0];
|
|
37
|
-
if (first && typeof first === "object" && "target" in first) return first.target?.value ?? "";
|
|
38
|
-
return first;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* useAddressForm — the checkout address form as a field list bound to the
|
|
43
|
-
* guided checkout. Needs a `<CheckoutProvider>` above it.
|
|
44
|
-
*
|
|
45
|
-
* Every field is **self-contained**: it carries its own value, setter, id,
|
|
46
|
-
* `autoComplete` token and error, so the whole form is one map and every
|
|
47
|
-
* element and attribute in it is yours:
|
|
48
|
-
*
|
|
49
|
-
* const { fields } = useAddressForm("billing");
|
|
50
|
-
* {fields.map(f => (
|
|
51
|
-
* <div key={f.key} className="…">
|
|
52
|
-
* <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
|
|
53
|
-
* {f.isSelect ? (
|
|
54
|
-
* <select id={f.id} value={f.value} onChange={f.set}
|
|
55
|
-
* autoComplete={f.autoComplete} className="…">
|
|
56
|
-
* <option value="">{f.placeholder}</option>
|
|
57
|
-
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
58
|
-
* </select>
|
|
59
|
-
* ) : <input id={f.id} type={f.type} value={f.value} onChange={f.set}
|
|
60
|
-
* autoComplete={f.autoComplete} className="…" />}
|
|
61
|
-
* {f.error && <span role="alert" className="…">{f.error}</span>}
|
|
62
|
-
* </div>
|
|
63
|
-
* ))}
|
|
64
|
-
*
|
|
65
|
-
* ⚑ Keep `value={f.value}` + `onChange={f.set}` (the pair that binds the field
|
|
66
|
-
* to checkout) and `autoComplete={f.autoComplete}` (browsers fill addresses in
|
|
67
|
-
* one gesture with it, field by field without). `f.placeholder` resolves the
|
|
68
|
-
* select's empty option ("Select Country", or "Loading…" while countries
|
|
69
|
-
* arrive), so `countriesLoading` never needs handling by hand. `f.error` is
|
|
70
|
-
* per-field: "we don't ship there" lands on the country field, and a required
|
|
71
|
-
* field reports itself once it has been edited and left empty — untouched
|
|
72
|
-
* fields stay quiet here and surface through the place-order `blockers`.
|
|
73
|
-
*
|
|
74
|
-
* `f.set` works with custom controls too — it accepts a value, the raw change
|
|
75
|
-
* event (`onChange={f.set}`) or a `(key, value)` pair — as does the hook's
|
|
76
|
-
* top-level `set(key, value)`.
|
|
77
|
-
*
|
|
78
|
-
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
79
|
-
* `useCheckout` debounces and calls `set-shipping-address` once the address is
|
|
80
|
-
* complete enough to price. Two things the field list gets right that a
|
|
81
|
-
* hand-typed table did not: **`state` is present** (rates and taxes match on
|
|
82
|
-
* country + state, and it switches to a select for countries with
|
|
83
|
-
* subdivisions), and the country options are never null.
|
|
84
|
-
*
|
|
85
|
-
* @param {"billing"|"shipping"} [which]
|
|
86
|
-
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
87
|
-
* @returns {{fields: Array<{key: string, id: string, label: string, type: string,
|
|
88
|
-
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
89
|
-
* autoComplete: string, colSpan: number, set: (...args: any[]) => void,
|
|
90
|
-
* isSelect: boolean, placeholder: string|undefined}>,
|
|
91
|
-
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
92
|
-
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
93
|
-
*/
|
|
94
|
-
export function useAddressForm(which = "billing", options = {}) {
|
|
95
|
-
const checkout = useCheckoutContext();
|
|
96
|
-
const { countries, loading: countriesLoading } = useCountries();
|
|
97
|
-
|
|
98
|
-
const isBilling = which === "billing";
|
|
99
|
-
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
100
|
-
const { updateBilling, updateShipping } = checkout;
|
|
101
|
-
const set = useCallback(
|
|
102
|
-
(key, value) => (isBilling ? updateBilling({ [key]: value }) : updateShipping({ [key]: value })),
|
|
103
|
-
[isBilling, updateBilling, updateShipping],
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
// `place-order` only enforces required fields on billing; a separate shipping
|
|
107
|
-
// address is priced, not validated field-by-field.
|
|
108
|
-
const missing = isBilling ? checkout.missingBillingFields : [];
|
|
109
|
-
|
|
110
|
-
// A required field reports itself as `error` only after it has been edited —
|
|
111
|
-
// an untouched form must not open covered in "required" marks. (Fields never
|
|
112
|
-
// touched at all surface through the place-order blockers instead.)
|
|
113
|
-
const [visited, setVisited] = useState({});
|
|
114
|
-
|
|
115
|
-
const fields = useMemo(() => {
|
|
116
|
-
const spec = addressFieldSpec({
|
|
117
|
-
countries,
|
|
118
|
-
country: values?.country,
|
|
119
|
-
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
120
|
-
includeEmail: isBilling, // one email per order, on billing
|
|
121
|
-
...options,
|
|
122
|
-
});
|
|
123
|
-
return spec.map((f) => {
|
|
124
|
-
const value = values?.[f.key] ?? "";
|
|
125
|
-
// Self-contained: the field knows its own key, so a .map never has to
|
|
126
|
-
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
127
|
-
// The first edit marks the field visited, arming its required check.
|
|
128
|
-
const setField = (...args) => {
|
|
129
|
-
setVisited((v) => (v[f.key] ? v : { ...v, [f.key]: true }));
|
|
130
|
-
set(f.key, newValue(args));
|
|
131
|
-
};
|
|
132
|
-
// The address-level error ("we don't ship there") belongs on country.
|
|
133
|
-
const error =
|
|
134
|
-
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
135
|
-
? checkout.addressError.message
|
|
136
|
-
: visited[f.key] && !value && missing.includes(f.key)
|
|
137
|
-
? `${f.label} is required.`
|
|
138
|
-
: null;
|
|
139
|
-
return {
|
|
140
|
-
...f,
|
|
141
|
-
id: `${which}-${f.key}`,
|
|
142
|
-
value,
|
|
143
|
-
set: setField,
|
|
144
|
-
error,
|
|
145
|
-
isSelect: f.type === "select",
|
|
146
|
-
placeholder:
|
|
147
|
-
f.type === "select"
|
|
148
|
-
? f.key === "country" && countriesLoading
|
|
149
|
-
? "Loading…"
|
|
150
|
-
: `Select ${f.label}`
|
|
151
|
-
: undefined,
|
|
152
|
-
};
|
|
153
|
-
});
|
|
154
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
155
|
-
}, [countries, countriesLoading, values, which, isBilling, set, checkout.addressError, visited, missing.join(","), JSON.stringify(options)]);
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
fields,
|
|
159
|
-
set,
|
|
160
|
-
values: values ?? {},
|
|
161
|
-
missing,
|
|
162
|
-
complete: missing.length === 0,
|
|
163
|
-
error: checkout.addressError ?? null,
|
|
164
|
-
countriesLoading,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import { useCheckoutContext } from "./useCheckout";
|
|
3
|
-
import { useCheckoutBlockers } from "./useTotalsLines";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* usePlaceOrder — the place-order gate as plain states and one handler. Needs
|
|
7
|
-
* a `<CheckoutProvider>` above it. The whole bottom of a checkout page:
|
|
8
|
-
*
|
|
9
|
-
* const order = usePlaceOrder();
|
|
10
|
-
* if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
|
|
11
|
-
* …
|
|
12
|
-
* <button type="button" onClick={order.placeOrder} disabled={order.disabled} className="…">
|
|
13
|
-
* {order.label}
|
|
14
|
-
* </button>
|
|
15
|
-
* {order.error && <p role="alert" className="…">{order.error.message}</p>}
|
|
16
|
-
* {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
|
|
17
|
-
*
|
|
18
|
-
* ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
|
|
19
|
-
* branch.** Placing an order clears the cart before the browser navigates
|
|
20
|
-
* away; without the guard the page flashes "your bag is empty" over a
|
|
21
|
-
* just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
|
|
22
|
-
*
|
|
23
|
-
* `disabled` is the gate plus in-flight (`!canPlaceOrder || placing`); `label`
|
|
24
|
-
* follows `placing` and is overridable via `labels: { idle, placing }`.
|
|
25
|
-
* `placeOrder` is safe as an `onClick` handler directly — a click event passed
|
|
26
|
-
* to it is ignored (an explicit `extra` object is still forwarded). `blockers`
|
|
27
|
-
* are the disabled button's reasons in words (`useCheckoutBlockers`), each
|
|
28
|
-
* with a `field` to anchor it next to the input that fixes it. Pass
|
|
29
|
-
* `blockerLabels` to override that copy per code.
|
|
30
|
-
*
|
|
31
|
-
* @param {{labels?: {idle?: string, placing?: string},
|
|
32
|
-
* blockerLabels?: Record<string, string>}} [options]
|
|
33
|
-
* @returns {{placeOrder: (extra?: object) => Promise<object>, disabled: boolean,
|
|
34
|
-
* label: string, stage: "editing"|"placing"|"submitted", submitted: boolean,
|
|
35
|
-
* placing: boolean, canPlaceOrder: boolean, error: object|null,
|
|
36
|
-
* blockers: Array<{code: string, message: string, field: string|null}>}}
|
|
37
|
-
*/
|
|
38
|
-
export function usePlaceOrder({ labels, blockerLabels } = {}) {
|
|
39
|
-
const checkout = useCheckoutContext();
|
|
40
|
-
const blockers = useCheckoutBlockers({ labels: blockerLabels });
|
|
41
|
-
const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
|
|
42
|
-
|
|
43
|
-
// Usable as onClick={order.placeOrder}: a DOM/React event is not `extra`.
|
|
44
|
-
const place = useCallback(
|
|
45
|
-
(extra) => {
|
|
46
|
-
const isEvent = extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
|
|
47
|
-
return placeOrder(isEvent ? undefined : extra);
|
|
48
|
-
},
|
|
49
|
-
[placeOrder],
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
placeOrder: place,
|
|
54
|
-
disabled: !canPlaceOrder || placing,
|
|
55
|
-
label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
|
|
56
|
-
stage,
|
|
57
|
-
submitted,
|
|
58
|
-
placing,
|
|
59
|
-
canPlaceOrder,
|
|
60
|
-
error: orderError,
|
|
61
|
-
blockers,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { imageIndex, productImages } from "@/commerce/utils";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* useProductGallery — the gallery's non-visual state: normalized images, the
|
|
6
|
-
* active index, and the variant-follows-selection behaviour.
|
|
7
|
-
*
|
|
8
|
-
* const g = useProductGallery(product, view);
|
|
9
|
-
* {g.hasImages
|
|
10
|
-
* ? <img src={g.active.src} alt={g.active.alt} />
|
|
11
|
-
* : <MyPlaceholder />}
|
|
12
|
-
* {g.images.map((img, i) => <Thumb key={img.src} onClick={() => g.setActiveIndex(i)} …/>)}
|
|
13
|
-
*
|
|
14
|
-
* The aspect ratio, crossfade and thumbnail styling stay yours — only the state
|
|
15
|
-
* moves here, and with it three details worth having by default: images are
|
|
16
|
-
* normalized to `{src, name, alt}` (they are stored as objects, so a raw
|
|
17
|
-
* `images[0]` as `src` renders broken), `hasImages: false` is an explicit
|
|
18
|
-
* placeholder signal rather than a collapsed element, and **a selection change
|
|
19
|
-
* moves the active image to the variation's own picture while a manual pick
|
|
20
|
-
* still wins until the selection changes again** — highlight, not replace.
|
|
21
|
-
*
|
|
22
|
-
* A null/not-yet-loaded product is fine (`hasImages: false`), so call this with
|
|
23
|
-
* the other hooks **above** the page's `loading`/`not_found` guards — a hook
|
|
24
|
-
* below an early return breaks the hook order the next render.
|
|
25
|
-
*
|
|
26
|
-
* @param {object} product
|
|
27
|
-
* @param {object|null} [view] a `resolveSelection` view; its
|
|
28
|
-
* `display.image` is the variation's image
|
|
29
|
-
*/
|
|
30
|
-
export function useProductGallery(product, view = null) {
|
|
31
|
-
const images = useMemo(() => productImages(product), [product]);
|
|
32
|
-
const [activeIndex, setActiveIndex] = useState(0);
|
|
33
|
-
const manualPick = useRef(false);
|
|
34
|
-
|
|
35
|
-
const variationImage = view?.display?.image ?? null;
|
|
36
|
-
const variationIndex = useMemo(() => imageIndex(images, variationImage), [images, variationImage]);
|
|
37
|
-
const selectionKey = JSON.stringify(view?.selection ?? {});
|
|
38
|
-
|
|
39
|
-
// A new product resets everything; a new selection re-arms the follow.
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
manualPick.current = false;
|
|
42
|
-
setActiveIndex(0);
|
|
43
|
-
}, [product?.id]);
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
manualPick.current = false;
|
|
47
|
-
}, [selectionKey]);
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (manualPick.current || variationIndex < 0) return;
|
|
51
|
-
setActiveIndex(variationIndex);
|
|
52
|
-
}, [variationIndex]);
|
|
53
|
-
|
|
54
|
-
const select = useCallback(
|
|
55
|
-
(index) => {
|
|
56
|
-
manualPick.current = true;
|
|
57
|
-
setActiveIndex(Math.max(0, Math.min(images.length - 1, index)));
|
|
58
|
-
},
|
|
59
|
-
[images.length],
|
|
60
|
-
);
|
|
61
|
-
const next = useCallback(() => select((activeIndex + 1) % Math.max(1, images.length)), [activeIndex, images.length, select]);
|
|
62
|
-
const prev = useCallback(
|
|
63
|
-
() => select((activeIndex - 1 + Math.max(1, images.length)) % Math.max(1, images.length)),
|
|
64
|
-
[activeIndex, images.length, select],
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const safeIndex = Math.min(activeIndex, Math.max(0, images.length - 1));
|
|
68
|
-
return {
|
|
69
|
-
images,
|
|
70
|
-
activeIndex: safeIndex,
|
|
71
|
-
setActiveIndex: select,
|
|
72
|
-
next,
|
|
73
|
-
prev,
|
|
74
|
-
active: images[safeIndex] ?? null,
|
|
75
|
-
hasImages: images.length > 0,
|
|
76
|
-
variationIndex,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo } from "react";
|
|
2
|
-
import { productPrice } from "@/commerce/utils";
|
|
3
|
-
import { useFormatMoney, useStoreInfo } from "./StorefrontProvider";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Money formatting in the store's currency, with the pieces a custom price UI
|
|
7
|
-
* needs (parity with the admin's `useMoney`).
|
|
8
|
-
*
|
|
9
|
-
* @returns {{format: (n: number) => string, formatRange: (a: number, b: number) => string,
|
|
10
|
-
* code: string|null, symbol: string, decimals: number}}
|
|
11
|
-
*/
|
|
12
|
-
export function useMoney() {
|
|
13
|
-
const format = useFormatMoney();
|
|
14
|
-
const { settings } = useStoreInfo();
|
|
15
|
-
const code = settings?.currency ?? null;
|
|
16
|
-
const formatRange = useCallback((min, max) => `${format(min)} – ${format(max)}`, [format]);
|
|
17
|
-
const symbol = useMemo(() => {
|
|
18
|
-
if (!code) return "";
|
|
19
|
-
try {
|
|
20
|
-
// The currency part of a formatted zero — locale-correct, no symbol table.
|
|
21
|
-
return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
|
|
22
|
-
.formatToParts(0)
|
|
23
|
-
.find((p) => p.type === "currency")?.value ?? code;
|
|
24
|
-
} catch {
|
|
25
|
-
return code;
|
|
26
|
-
}
|
|
27
|
-
}, [code]);
|
|
28
|
-
const decimals = useMemo(() => {
|
|
29
|
-
if (!code) return 2;
|
|
30
|
-
try {
|
|
31
|
-
return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
|
|
32
|
-
.resolvedOptions().maximumFractionDigits ?? 2;
|
|
33
|
-
} catch {
|
|
34
|
-
return 2;
|
|
35
|
-
}
|
|
36
|
-
}, [code]);
|
|
37
|
-
|
|
38
|
-
return { format, formatRange, code, symbol, decimals };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The rendered price of a product row **or** a resolved selection view, in the
|
|
43
|
-
* store's currency:
|
|
44
|
-
*
|
|
45
|
-
* const price = useProductPrice(row); // a card: "From €19.99"
|
|
46
|
-
* const price = useProductPrice(view); // a product page: "€19.99 – €23.99"
|
|
47
|
-
* // until the selection resolves
|
|
48
|
-
* <span>{price.label}</span>
|
|
49
|
-
* {price.compareAtLabel && <s>{price.compareAtLabel}</s>}
|
|
50
|
-
*
|
|
51
|
-
* One call for both views is the point — the from-price rule cannot be honoured
|
|
52
|
-
* on the grid and forgotten on the product page. See `productPrice` for the
|
|
53
|
-
* rules themselves.
|
|
54
|
-
*/
|
|
55
|
-
export function useProductPrice(rowOrView) {
|
|
56
|
-
const formatMoney = useFormatMoney();
|
|
57
|
-
return useMemo(() => productPrice(rowOrView, { formatMoney }), [rowOrView, formatMoney]);
|
|
58
|
-
}
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from "react";
|
|
2
|
-
import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
|
-
import { useStorefront } from "./StorefrontProvider";
|
|
4
|
-
import { useAsyncData } from "./internal/useAsyncData";
|
|
5
|
-
|
|
6
|
-
const EMPTY_FORM = Object.freeze({ reviewer: "", email: "", review: "", rating: 5 });
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* useProductReviews — the review list AND the submit form, including the store
|
|
10
|
-
* policy, in one hook.
|
|
11
|
-
*
|
|
12
|
-
* const r = useProductReviews(product, { policy: "open" });
|
|
13
|
-
* // list: r.items, r.averageRating, r.ratingCount, r.hasNext, r.loadMore()
|
|
14
|
-
* // form: r.form, r.setField("review", v), r.fieldErrors, r.valid, r.submit()
|
|
15
|
-
* // after: r.submitted && <p>{r.message}</p>
|
|
16
|
-
*
|
|
17
|
-
* The backend ships complete (public submission by email, moderation,
|
|
18
|
-
* auto-approve); everything that used to be hand-rolled around it lives here:
|
|
19
|
-
*
|
|
20
|
-
* - **The confirmation copy comes from the response.** `submit()` returns the
|
|
21
|
-
* server's `status` — `"approved"` when the store has `auto_approve_reviews`
|
|
22
|
-
* on, `"hold"` otherwise — and `message` follows it. A hardcoded "awaiting
|
|
23
|
-
* approval" lies to half of all stores.
|
|
24
|
-
* - **Errors are field-level.** The three server codes (`email_required`,
|
|
25
|
-
* `review_incomplete`, `invalid_rating`) land in `fieldErrors` rather than
|
|
26
|
-
* rejecting into nothing, and `valid` gates the round trip.
|
|
27
|
-
* - **Submitting refreshes the list**, so an auto-approved review appears
|
|
28
|
-
* instead of being invisible until the next page load.
|
|
29
|
-
* - **`requiresEmail` is false for a signed-in visitor** — the session's email
|
|
30
|
-
* always wins server-side, so asking for it again is a field that does
|
|
31
|
-
* nothing.
|
|
32
|
-
*
|
|
33
|
-
* `policy` replaces the prose patterns a storefront used to implement by hand:
|
|
34
|
-
* `"open"` (anyone with an email — the server's own default), `"login"` (only a
|
|
35
|
-
* signed-in visitor; `canReview` false with `reviewBlockedReason:
|
|
36
|
-
* "login_required"`), `"verified_buyers"` (only someone whose email has a
|
|
37
|
-
* completed order for this product — `"not_a_buyer"`). Policies are UI-side by
|
|
38
|
-
* design: the server accepts any valid email, so a stricter rule is exactly
|
|
39
|
-
* this gate.
|
|
40
|
-
*
|
|
41
|
-
* @param {object} product the product being reviewed (needs `id`, and `slug` to page)
|
|
42
|
-
* @param {{perPage?: number, policy?: "open"|"login"|"verified_buyers",
|
|
43
|
-
* requireRating?: boolean, refreshOnSubmit?: boolean, user?: object|null,
|
|
44
|
-
* initialReviews?: object}} [options]
|
|
45
|
-
* `user` is your app's current user (Base44 auth) — pass it for the `login`
|
|
46
|
-
* and `verified_buyers` policies and to drop the email field.
|
|
47
|
-
*/
|
|
48
|
-
export function useProductReviews(product, options = {}) {
|
|
49
|
-
const {
|
|
50
|
-
perPage = 10,
|
|
51
|
-
policy = "open",
|
|
52
|
-
requireRating = false,
|
|
53
|
-
refreshOnSubmit = true,
|
|
54
|
-
user = null,
|
|
55
|
-
initialReviews = null,
|
|
56
|
-
} = options;
|
|
57
|
-
|
|
58
|
-
const store = useStorefront();
|
|
59
|
-
const [page, setPage] = useState(1);
|
|
60
|
-
const [appended, setAppended] = useState(null);
|
|
61
|
-
const ref = product?.slug ? product.slug : product?.id ? { id: product.id } : null;
|
|
62
|
-
|
|
63
|
-
const { data, loading, refreshing, error, reload } = useAsyncData(
|
|
64
|
-
() => store.getProductReviews(ref, { page, per_page: perPage }),
|
|
65
|
-
[store, product?.id, page, perPage],
|
|
66
|
-
{ enabled: Boolean(ref), initialData: page === 1 ? initialReviews : null },
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const items = useMemo(() => {
|
|
70
|
-
const rows = data?.items ?? [];
|
|
71
|
-
if (!appended) return rows;
|
|
72
|
-
const byId = new Map();
|
|
73
|
-
for (const r of [...appended, ...rows]) byId.set(r.id ?? `${r.reviewer}-${r.date_created}`, r);
|
|
74
|
-
return [...byId.values()];
|
|
75
|
-
}, [data, appended]);
|
|
76
|
-
|
|
77
|
-
const hasNext = Boolean(data?.has_next);
|
|
78
|
-
const next = useCallback(() => {
|
|
79
|
-
if (hasNext) setPage((p) => p + 1);
|
|
80
|
-
}, [hasNext]);
|
|
81
|
-
const prev = useCallback(() => setPage((p) => Math.max(1, p - 1)), []);
|
|
82
|
-
const loadMore = useCallback(() => {
|
|
83
|
-
if (!hasNext) return;
|
|
84
|
-
setAppended(items);
|
|
85
|
-
setPage((p) => p + 1);
|
|
86
|
-
}, [hasNext, items]);
|
|
87
|
-
|
|
88
|
-
// ── the form ───────────────────────────────────────────────────────────────
|
|
89
|
-
const [form, setForm] = useState(() => ({
|
|
90
|
-
...EMPTY_FORM,
|
|
91
|
-
reviewer: user?.full_name ?? "",
|
|
92
|
-
email: user?.email ?? "",
|
|
93
|
-
}));
|
|
94
|
-
const [submitting, setSubmitting] = useState(false);
|
|
95
|
-
const [submitted, setSubmitted] = useState(false);
|
|
96
|
-
const [submittedStatus, setSubmittedStatus] = useState(null);
|
|
97
|
-
const [serverError, setServerError] = useState(null);
|
|
98
|
-
|
|
99
|
-
const setField = useCallback((name, value) => {
|
|
100
|
-
setServerError(null);
|
|
101
|
-
setForm((f) => ({ ...f, [name]: value }));
|
|
102
|
-
}, []);
|
|
103
|
-
const reset = useCallback(() => {
|
|
104
|
-
setForm({ ...EMPTY_FORM, reviewer: user?.full_name ?? "", email: user?.email ?? "" });
|
|
105
|
-
setSubmitted(false);
|
|
106
|
-
setSubmittedStatus(null);
|
|
107
|
-
setServerError(null);
|
|
108
|
-
}, [user?.full_name, user?.email]);
|
|
109
|
-
|
|
110
|
-
// A signed-in visitor's email is taken from the session server-side.
|
|
111
|
-
const requiresEmail = !user?.email;
|
|
112
|
-
|
|
113
|
-
const fieldErrors = useMemo(() => {
|
|
114
|
-
const errs = {};
|
|
115
|
-
if (requiresEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(form.email ?? "").trim())) {
|
|
116
|
-
errs.email = "A valid email address is required.";
|
|
117
|
-
}
|
|
118
|
-
if (!String(form.review ?? "").trim()) errs.review = "Write a few words about the product.";
|
|
119
|
-
const rating = Number(form.rating);
|
|
120
|
-
if (requireRating && !(rating >= 1 && rating <= 5)) errs.rating = "Choose a rating.";
|
|
121
|
-
else if (form.rating != null && (rating < 0 || rating > 5)) errs.rating = "Rating must be 0–5.";
|
|
122
|
-
if (serverError) errs[serverErrorField(serverError.code)] = serverError.message;
|
|
123
|
-
return errs;
|
|
124
|
-
}, [form, requiresEmail, requireRating, serverError]);
|
|
125
|
-
|
|
126
|
-
// ── policy ─────────────────────────────────────────────────────────────────
|
|
127
|
-
// "Has this visitor bought it?" can only be answered from their own orders —
|
|
128
|
-
// public reviews carry no email (by design), so the answer comes from
|
|
129
|
-
// `my-orders`. Fetched only for the policy that needs it.
|
|
130
|
-
const needsPurchaseCheck = policy === "verified_buyers" && Boolean(user) && Boolean(product?.id);
|
|
131
|
-
const { data: myOrders, loading: purchaseLoading } = useAsyncData(
|
|
132
|
-
() => store.inv("commerce/storefront-account", { action: "my-orders", per_page: 50 }),
|
|
133
|
-
[store, user?.email, product?.id],
|
|
134
|
-
{ enabled: needsPurchaseCheck },
|
|
135
|
-
);
|
|
136
|
-
const purchased = useMemo(() => {
|
|
137
|
-
if (!needsPurchaseCheck) return false;
|
|
138
|
-
return (myOrders?.orders ?? []).some(
|
|
139
|
-
(o) =>
|
|
140
|
-
["completed", "processing"].includes(o.status) &&
|
|
141
|
-
(o.line_items ?? []).some((li) => li.product_id === product.id),
|
|
142
|
-
);
|
|
143
|
-
}, [needsPurchaseCheck, myOrders, product?.id]);
|
|
144
|
-
|
|
145
|
-
let reviewBlockedReason = null;
|
|
146
|
-
if ((policy === "login" || policy === "verified_buyers") && !user) {
|
|
147
|
-
reviewBlockedReason = "login_required";
|
|
148
|
-
} else if (policy === "verified_buyers" && !purchased && !purchaseLoading) {
|
|
149
|
-
reviewBlockedReason = "not_a_buyer";
|
|
150
|
-
}
|
|
151
|
-
const canReview = !reviewBlockedReason && !purchaseLoading && !submitted;
|
|
152
|
-
|
|
153
|
-
const valid = canReview && Object.keys(fieldErrors).length === 0;
|
|
154
|
-
|
|
155
|
-
const submit = useCallback(async () => {
|
|
156
|
-
if (submitting) return { ok: false, error: { code: "submitting", message: "Already submitting." } };
|
|
157
|
-
if (reviewBlockedReason) {
|
|
158
|
-
return { ok: false, error: { code: reviewBlockedReason, message: blockedMessage(reviewBlockedReason) } };
|
|
159
|
-
}
|
|
160
|
-
if (Object.keys(fieldErrors).length) {
|
|
161
|
-
return { ok: false, error: { code: "invalid_form", message: "Please complete the form." } };
|
|
162
|
-
}
|
|
163
|
-
setSubmitting(true);
|
|
164
|
-
setServerError(null);
|
|
165
|
-
try {
|
|
166
|
-
const res = await store.submitReview({
|
|
167
|
-
product_id: product?.id,
|
|
168
|
-
review: String(form.review).trim(),
|
|
169
|
-
rating: form.rating == null ? undefined : Number(form.rating),
|
|
170
|
-
reviewer: String(form.reviewer ?? "").trim() || undefined,
|
|
171
|
-
email: requiresEmail ? String(form.email).trim() : undefined,
|
|
172
|
-
});
|
|
173
|
-
setSubmitted(true);
|
|
174
|
-
setSubmittedStatus(res.status);
|
|
175
|
-
// An auto-approved review is live immediately — show it.
|
|
176
|
-
if (refreshOnSubmit && res.status === "approved") {
|
|
177
|
-
setAppended(null);
|
|
178
|
-
setPage(1);
|
|
179
|
-
reload();
|
|
180
|
-
}
|
|
181
|
-
return { ok: true, status: res.status, verified: res.verified };
|
|
182
|
-
} catch (e) {
|
|
183
|
-
const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
|
|
184
|
-
setServerError(err);
|
|
185
|
-
return { ok: false, error: err };
|
|
186
|
-
} finally {
|
|
187
|
-
setSubmitting(false);
|
|
188
|
-
}
|
|
189
|
-
}, [submitting, reviewBlockedReason, fieldErrors, store, product?.id, form, requiresEmail, refreshOnSubmit, reload]);
|
|
190
|
-
|
|
191
|
-
const message = submitted
|
|
192
|
-
? submittedStatus === "approved"
|
|
193
|
-
? "Thanks — your review is published."
|
|
194
|
-
: "Thanks — your review has been submitted for approval."
|
|
195
|
-
: null;
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
// list
|
|
199
|
-
items,
|
|
200
|
-
page,
|
|
201
|
-
perPage: data?.per_page ?? perPage,
|
|
202
|
-
hasNext,
|
|
203
|
-
next,
|
|
204
|
-
prev,
|
|
205
|
-
loadMore,
|
|
206
|
-
averageRating: data?.average_rating ?? product?.average_rating ?? 0,
|
|
207
|
-
ratingCount: data?.rating_count ?? product?.rating_count ?? 0,
|
|
208
|
-
loading,
|
|
209
|
-
refreshing,
|
|
210
|
-
error,
|
|
211
|
-
reload,
|
|
212
|
-
// submission
|
|
213
|
-
form,
|
|
214
|
-
setField,
|
|
215
|
-
reset,
|
|
216
|
-
fieldErrors,
|
|
217
|
-
valid,
|
|
218
|
-
submit,
|
|
219
|
-
submitting,
|
|
220
|
-
submitted,
|
|
221
|
-
submittedStatus,
|
|
222
|
-
message,
|
|
223
|
-
// policy
|
|
224
|
-
canReview,
|
|
225
|
-
reviewBlockedReason,
|
|
226
|
-
requiresEmail,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Server error code → the form field it belongs on. */
|
|
231
|
-
function serverErrorField(code) {
|
|
232
|
-
if (code === "email_required") return "email";
|
|
233
|
-
if (code === "invalid_rating") return "rating";
|
|
234
|
-
if (code === "review_incomplete") return "review";
|
|
235
|
-
return "form";
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function blockedMessage(reason) {
|
|
239
|
-
if (reason === "login_required") return "Please sign in to write a review.";
|
|
240
|
-
if (reason === "not_a_buyer") return "Only verified buyers can review this product.";
|
|
241
|
-
return "Reviews are closed.";
|
|
242
|
-
}
|