@base44/app-plugin-commerce 0.1.20 → 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/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- 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 -496
- 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,227 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
defaultSelection,
|
|
4
|
+
resolveSelection,
|
|
5
|
+
selectOption,
|
|
6
|
+
selectionFromParams,
|
|
7
|
+
selectionToParams,
|
|
8
|
+
storefrontErrorCode,
|
|
9
|
+
storefrontErrorMessage,
|
|
10
|
+
} from "@/commerce/utils";
|
|
11
|
+
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
12
|
+
import { useAsyncData } from "./internal/useAsyncData";
|
|
13
|
+
import { useProductPrice } from "./useProductPrice";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* useProduct — the product page's whole data and selection lifecycle.
|
|
17
|
+
*
|
|
18
|
+
* const { status, product, view, pick, price, quantity, incQuantity } =
|
|
19
|
+
* useProduct(slug);
|
|
20
|
+
* if (status === "loading") return <Skeleton />;
|
|
21
|
+
* if (status === "not_found") return <NotFound />;
|
|
22
|
+
* // render: price.label, view.axes (one control each), view.availability,
|
|
23
|
+
* // view.purchasable, view.addToCart
|
|
24
|
+
*
|
|
25
|
+
* It composes the variant helpers so their rules hold by default:
|
|
26
|
+
* `defaultSelection` seeds the merchant's defaults, `selectOption` applies a
|
|
27
|
+
* click without creating dead ends, `resolveSelection` recomputes the view, and
|
|
28
|
+
* `price` renders a *range* while the selection is incomplete instead of
|
|
29
|
+
* passing the parent's from-price off as the price.
|
|
30
|
+
*
|
|
31
|
+
* Three lifecycle facts a hand-written effect misses: **a missing product is
|
|
32
|
+
* `status: "not_found"`**, not a permanent spinner; a second `getProduct` in
|
|
33
|
+
* flight can never paint over a newer one; and the selection is mirrored to the
|
|
34
|
+
* URL (`?color=Ivory`) so a chosen variant is linkable and survives a reload.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|{id: string}} ref slug or `{ id }`
|
|
37
|
+
* @param {{syncSelectionToUrl?: boolean, reviewsPerPage?: number,
|
|
38
|
+
* initialSelection?: object}} [options]
|
|
39
|
+
*/
|
|
40
|
+
export function useProduct(ref, options = {}) {
|
|
41
|
+
const { syncSelectionToUrl = true, reviewsPerPage, initialSelection } = options;
|
|
42
|
+
const store = useStorefront();
|
|
43
|
+
const refKey = typeof ref === "string" ? ref : JSON.stringify(ref ?? null);
|
|
44
|
+
|
|
45
|
+
const { data, loading, error, reload } = useAsyncData(
|
|
46
|
+
() => store.getProduct(ref, reviewsPerPage ? { reviews_per_page: reviewsPerPage } : {}),
|
|
47
|
+
[store, refKey, reviewsPerPage],
|
|
48
|
+
{ keepPreviousData: false },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const product = data?.product ?? null;
|
|
52
|
+
const variations = data?.variations ?? [];
|
|
53
|
+
|
|
54
|
+
// ── selection ──────────────────────────────────────────────────────────────
|
|
55
|
+
const [selection, setSelection] = useState(initialSelection ?? {});
|
|
56
|
+
const [touched, setTouched] = useState(false);
|
|
57
|
+
|
|
58
|
+
// Seed from the URL first (a shared link names a variant), then the
|
|
59
|
+
// merchant's defaults. Re-seeds when the product changes, never after a
|
|
60
|
+
// customer has started picking.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!product || touched) return;
|
|
63
|
+
const fromUrl =
|
|
64
|
+
syncSelectionToUrl && typeof window !== "undefined"
|
|
65
|
+
? selectionFromParams(product, variations, new URLSearchParams(window.location.search))
|
|
66
|
+
: null;
|
|
67
|
+
const seeded = { ...defaultSelection(product, variations), ...(fromUrl ?? {}) };
|
|
68
|
+
setSelection(initialSelection ?? seeded);
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [product?.id]);
|
|
71
|
+
|
|
72
|
+
const view = useMemo(
|
|
73
|
+
() => (product ? resolveSelection(product, variations, selection) : null),
|
|
74
|
+
[product, variations, selection],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const pick = useCallback(
|
|
78
|
+
(axisKey, option) => {
|
|
79
|
+
if (!product) return;
|
|
80
|
+
setTouched(true);
|
|
81
|
+
setSelection((prev) => selectOption(product, variations, prev, axisKey, option));
|
|
82
|
+
},
|
|
83
|
+
[product, variations],
|
|
84
|
+
);
|
|
85
|
+
const resetSelection = useCallback(() => {
|
|
86
|
+
setTouched(false);
|
|
87
|
+
setSelection(product ? defaultSelection(product, variations) : {});
|
|
88
|
+
}, [product, variations]);
|
|
89
|
+
|
|
90
|
+
// Mirror the resolved selection into the URL without adding history entries.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!syncSelectionToUrl || !product || typeof window === "undefined" || !touched) return;
|
|
93
|
+
const params = new URLSearchParams(window.location.search);
|
|
94
|
+
for (const [k, v] of Object.entries(selectionToParams(product, selection))) params.set(k, v);
|
|
95
|
+
const qs = params.toString();
|
|
96
|
+
window.history.replaceState(
|
|
97
|
+
window.history.state,
|
|
98
|
+
"",
|
|
99
|
+
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
|
|
100
|
+
);
|
|
101
|
+
}, [syncSelectionToUrl, product, selection, touched]);
|
|
102
|
+
|
|
103
|
+
// ── quantity (respects sold_individually and the tracked stock) ─────────────
|
|
104
|
+
const [quantity, setQuantityState] = useState(1);
|
|
105
|
+
useEffect(() => setQuantityState(1), [product?.id, view?.variation?.id]);
|
|
106
|
+
|
|
107
|
+
const maxQuantity = useMemo(() => {
|
|
108
|
+
if (product?.sold_individually) return 1;
|
|
109
|
+
const stock = view?.display?.stock_quantity;
|
|
110
|
+
// Untracked stock (null) or backorders allowed means no ceiling to enforce.
|
|
111
|
+
if (stock == null || view?.display?.backorders !== "no") return Infinity;
|
|
112
|
+
return Math.max(1, stock);
|
|
113
|
+
}, [product?.sold_individually, view?.display?.stock_quantity, view?.display?.backorders]);
|
|
114
|
+
|
|
115
|
+
const setQuantity = useCallback(
|
|
116
|
+
(n) => setQuantityState(Math.max(1, Math.min(maxQuantity, Math.floor(Number(n) || 1)))),
|
|
117
|
+
[maxQuantity],
|
|
118
|
+
);
|
|
119
|
+
const incQuantity = useCallback(() => setQuantity(quantity + 1), [quantity, setQuantity]);
|
|
120
|
+
const decQuantity = useCallback(() => setQuantity(quantity - 1), [quantity, setQuantity]);
|
|
121
|
+
|
|
122
|
+
const price = useProductPrice(view);
|
|
123
|
+
|
|
124
|
+
const notFound = error?.code === "not_found";
|
|
125
|
+
const status = loading
|
|
126
|
+
? "loading"
|
|
127
|
+
: notFound
|
|
128
|
+
? "not_found"
|
|
129
|
+
: error
|
|
130
|
+
? "error"
|
|
131
|
+
: product
|
|
132
|
+
? "ready"
|
|
133
|
+
: "not_found";
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
status,
|
|
137
|
+
loading,
|
|
138
|
+
error: notFound ? null : error,
|
|
139
|
+
notFound,
|
|
140
|
+
reload,
|
|
141
|
+
product,
|
|
142
|
+
variations,
|
|
143
|
+
categories: data?.categories ?? [],
|
|
144
|
+
ribbons: data?.ribbons ?? [],
|
|
145
|
+
upsells: data?.upsells ?? [],
|
|
146
|
+
crossSells: data?.cross_sells ?? [],
|
|
147
|
+
reviews: data?.reviews ?? null,
|
|
148
|
+
view,
|
|
149
|
+
selection,
|
|
150
|
+
pick,
|
|
151
|
+
setSelection,
|
|
152
|
+
resetSelection,
|
|
153
|
+
quantity,
|
|
154
|
+
setQuantity,
|
|
155
|
+
incQuantity,
|
|
156
|
+
decQuantity,
|
|
157
|
+
maxQuantity,
|
|
158
|
+
canIncrease: quantity < maxQuantity,
|
|
159
|
+
price,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* useAddToCart — add-to-cart with its failure states handled.
|
|
165
|
+
*
|
|
166
|
+
* const { add, adding, error } = useAddToCart();
|
|
167
|
+
* <button disabled={!view.purchasable || adding}
|
|
168
|
+
* onClick={() => add(view.addToCart, quantity)}>
|
|
169
|
+
* {adding ? "Adding…" : "Add to bag"}
|
|
170
|
+
* </button>
|
|
171
|
+
* {error && <p role="alert">{error.message}</p>}
|
|
172
|
+
*
|
|
173
|
+
* `add()` **never throws** and always resolves — `{ ok: true, cart }` or
|
|
174
|
+
* `{ ok: false, error: { code, message, shouldReload } }`. That matters because
|
|
175
|
+
* the natural hand-written version (`await addItem(...)` with no catch) leaves
|
|
176
|
+
* the button stuck on "Adding…" forever the first time a variant sells out.
|
|
177
|
+
*
|
|
178
|
+
* `shouldReload` is set for `variation_not_found` — the page's data is stale,
|
|
179
|
+
* so call the product's `reload()`.
|
|
180
|
+
*/
|
|
181
|
+
export function useAddToCart() {
|
|
182
|
+
const { addItem } = useCart();
|
|
183
|
+
const [adding, setAdding] = useState(false);
|
|
184
|
+
const [error, setError] = useState(null);
|
|
185
|
+
const [lastAdded, setLastAdded] = useState(null);
|
|
186
|
+
|
|
187
|
+
const add = useCallback(
|
|
188
|
+
async (addToCartRef, quantity = 1) => {
|
|
189
|
+
if (adding) return { ok: false, error: { code: "adding", message: "Already adding." } };
|
|
190
|
+
if (!addToCartRef) {
|
|
191
|
+
const err = {
|
|
192
|
+
code: "variation_required",
|
|
193
|
+
message: "Choose an option first.",
|
|
194
|
+
shouldReload: false,
|
|
195
|
+
};
|
|
196
|
+
setError(err);
|
|
197
|
+
return { ok: false, error: err };
|
|
198
|
+
}
|
|
199
|
+
setAdding(true);
|
|
200
|
+
setError(null);
|
|
201
|
+
try {
|
|
202
|
+
const cart = await addItem(addToCartRef, quantity);
|
|
203
|
+
setLastAdded({ ...addToCartRef, quantity });
|
|
204
|
+
return { ok: true, cart };
|
|
205
|
+
} catch (e) {
|
|
206
|
+
const code = storefrontErrorCode(e) ?? "error";
|
|
207
|
+
const err = {
|
|
208
|
+
code,
|
|
209
|
+
message: storefrontErrorMessage(e),
|
|
210
|
+
shouldReload: code === "variation_not_found",
|
|
211
|
+
};
|
|
212
|
+
setError(err);
|
|
213
|
+
return { ok: false, error: err };
|
|
214
|
+
} finally {
|
|
215
|
+
setAdding(false);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
[adding, addItem],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const reset = useCallback(() => {
|
|
222
|
+
setError(null);
|
|
223
|
+
setLastAdded(null);
|
|
224
|
+
}, []);
|
|
225
|
+
|
|
226
|
+
return { add, adding, error, lastAdded, reset };
|
|
227
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
* @param {object} product
|
|
23
|
+
* @param {object|null} [view] a `resolveSelection` view; its
|
|
24
|
+
* `display.image` is the variation's image
|
|
25
|
+
*/
|
|
26
|
+
export function useProductGallery(product, view = null) {
|
|
27
|
+
const images = useMemo(() => productImages(product), [product]);
|
|
28
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
29
|
+
const manualPick = useRef(false);
|
|
30
|
+
|
|
31
|
+
const variationImage = view?.display?.image ?? null;
|
|
32
|
+
const variationIndex = useMemo(() => imageIndex(images, variationImage), [images, variationImage]);
|
|
33
|
+
const selectionKey = JSON.stringify(view?.selection ?? {});
|
|
34
|
+
|
|
35
|
+
// A new product resets everything; a new selection re-arms the follow.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
manualPick.current = false;
|
|
38
|
+
setActiveIndex(0);
|
|
39
|
+
}, [product?.id]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
manualPick.current = false;
|
|
43
|
+
}, [selectionKey]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (manualPick.current || variationIndex < 0) return;
|
|
47
|
+
setActiveIndex(variationIndex);
|
|
48
|
+
}, [variationIndex]);
|
|
49
|
+
|
|
50
|
+
const select = useCallback(
|
|
51
|
+
(index) => {
|
|
52
|
+
manualPick.current = true;
|
|
53
|
+
setActiveIndex(Math.max(0, Math.min(images.length - 1, index)));
|
|
54
|
+
},
|
|
55
|
+
[images.length],
|
|
56
|
+
);
|
|
57
|
+
const next = useCallback(() => select((activeIndex + 1) % Math.max(1, images.length)), [activeIndex, images.length, select]);
|
|
58
|
+
const prev = useCallback(
|
|
59
|
+
() => select((activeIndex - 1 + Math.max(1, images.length)) % Math.max(1, images.length)),
|
|
60
|
+
[activeIndex, images.length, select],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const safeIndex = Math.min(activeIndex, Math.max(0, images.length - 1));
|
|
64
|
+
return {
|
|
65
|
+
images,
|
|
66
|
+
activeIndex: safeIndex,
|
|
67
|
+
setActiveIndex: select,
|
|
68
|
+
next,
|
|
69
|
+
prev,
|
|
70
|
+
active: images[safeIndex] ?? null,
|
|
71
|
+
hasImages: images.length > 0,
|
|
72
|
+
variationIndex,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useStorefront } from "./StorefrontProvider";
|
|
3
|
+
import { useAsyncData } from "./internal/useAsyncData";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useProductList — a catalog listing with its state solved: paging, filters,
|
|
7
|
+
* loading vs refreshing, and failure as a visible state.
|
|
8
|
+
*
|
|
9
|
+
* const list = useProductList({ per_page: 12, sort: "-created_date" });
|
|
10
|
+
* // list.status: "loading" | "ready" | "empty" | "error"
|
|
11
|
+
* // list.products, list.hasNext, list.next(), list.setParams({ category_id })
|
|
12
|
+
*
|
|
13
|
+
* A short strip is the same hook with a small `per_page` — a featured rail, a
|
|
14
|
+
* "new in" row, four picks beside an article:
|
|
15
|
+
*
|
|
16
|
+
* const featured = useProductList({ featured: true, per_page: 4 });
|
|
17
|
+
*
|
|
18
|
+
* What it fixes versus a hand-written fetch effect: `has_next` is honoured (so
|
|
19
|
+
* the catalog isn't silently capped at one page), a failed request renders as
|
|
20
|
+
* `status: "error"` instead of an empty grid, `isEmpty` is never true while
|
|
21
|
+
* loading, changing a filter resets to page 1 and keeps the current rows on
|
|
22
|
+
* screen while the new page loads, and the whole server-side filter surface
|
|
23
|
+
* (`search`, `category_id`, `ribbon_id`, `featured`, `on_sale`, `min_price`,
|
|
24
|
+
* `max_price`, `in_stock_only`, `sort`) is reachable through `setParams`.
|
|
25
|
+
*
|
|
26
|
+
* Any filter may legitimately match nothing — render from
|
|
27
|
+
* `products.length`/`isEmpty`, never on the assumption that rows came back.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [initialParams] `list-products` params (page/per_page and any filter)
|
|
30
|
+
* @param {{mode?: "pages"|"append", perPage?: number, keepPreviousData?: boolean}} [options]
|
|
31
|
+
* `mode: "append"` accumulates pages for a "load more" / infinite-scroll
|
|
32
|
+
* catalog; `loadMore()` is then the paging call.
|
|
33
|
+
*/
|
|
34
|
+
export function useProductList(initialParams = {}, options = {}) {
|
|
35
|
+
const { mode = "pages", perPage: perPageOption, keepPreviousData = true } = options;
|
|
36
|
+
const store = useStorefront();
|
|
37
|
+
|
|
38
|
+
const [params, setParamsState] = useState(() => ({
|
|
39
|
+
page: 1,
|
|
40
|
+
per_page: perPageOption ?? initialParams.per_page ?? 12,
|
|
41
|
+
...initialParams,
|
|
42
|
+
}));
|
|
43
|
+
// In append mode the accumulated rows live here; `page` still drives fetching.
|
|
44
|
+
const [appended, setAppended] = useState([]);
|
|
45
|
+
const appendMode = mode === "append";
|
|
46
|
+
const paramsKey = JSON.stringify(params);
|
|
47
|
+
const lastFilterKey = useRef(null);
|
|
48
|
+
|
|
49
|
+
const { data, loading, refreshing, error, reload, reloadQuiet } = useAsyncData(
|
|
50
|
+
() => store.listProducts(params),
|
|
51
|
+
[store, paramsKey],
|
|
52
|
+
{ keepPreviousData },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// In append mode `loadMore` accumulates the rows it already has; the only
|
|
56
|
+
// other thing to handle is a FILTER change (anything but the page), which must
|
|
57
|
+
// drop the accumulation — otherwise "load more" stacks unrelated result sets.
|
|
58
|
+
const { page: _page, ...filters } = params;
|
|
59
|
+
const filterKey = JSON.stringify(filters);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!appendMode) return;
|
|
62
|
+
if (lastFilterKey.current === filterKey) return;
|
|
63
|
+
lastFilterKey.current = filterKey;
|
|
64
|
+
setAppended([]);
|
|
65
|
+
}, [appendMode, filterKey]);
|
|
66
|
+
|
|
67
|
+
const products = useMemo(() => {
|
|
68
|
+
const rows = data?.products ?? [];
|
|
69
|
+
if (!appendMode) return rows;
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
for (const p of [...appended, ...rows]) byId.set(p.id, p);
|
|
72
|
+
return [...byId.values()];
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [appendMode, appended, data]);
|
|
75
|
+
|
|
76
|
+
/** Patch params; any change other than the page itself returns to page 1. */
|
|
77
|
+
const setParams = useCallback((patch) => {
|
|
78
|
+
setParamsState((prev) => {
|
|
79
|
+
const next = { ...prev, ...patch };
|
|
80
|
+
if (!("page" in patch)) next.page = 1;
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}, []);
|
|
84
|
+
const resetParams = useCallback(() => {
|
|
85
|
+
setAppended([]);
|
|
86
|
+
setParamsState({ page: 1, per_page: perPageOption ?? initialParams.per_page ?? 12, ...initialParams });
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [perPageOption]);
|
|
89
|
+
|
|
90
|
+
const goToPage = useCallback((page) => setParams({ page: Math.max(1, page) }), [setParams]);
|
|
91
|
+
const hasNext = Boolean(data?.has_next);
|
|
92
|
+
const next = useCallback(() => {
|
|
93
|
+
if (hasNext) setParamsState((p) => ({ ...p, page: (p.page ?? 1) + 1 }));
|
|
94
|
+
}, [hasNext]);
|
|
95
|
+
const prev = useCallback(() => {
|
|
96
|
+
setParamsState((p) => ({ ...p, page: Math.max(1, (p.page ?? 1) - 1) }));
|
|
97
|
+
}, []);
|
|
98
|
+
const loadMore = useCallback(() => {
|
|
99
|
+
if (!hasNext) return;
|
|
100
|
+
setAppended(products);
|
|
101
|
+
setParamsState((p) => ({ ...p, page: (p.page ?? 1) + 1 }));
|
|
102
|
+
}, [hasNext, products]);
|
|
103
|
+
|
|
104
|
+
const isEmpty = !loading && !error && products.length === 0;
|
|
105
|
+
const status = loading ? "loading" : error ? "error" : isEmpty ? "empty" : "ready";
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
products,
|
|
109
|
+
page: data?.page ?? params.page ?? 1,
|
|
110
|
+
perPage: data?.per_page ?? params.per_page,
|
|
111
|
+
hasNext,
|
|
112
|
+
totalLoaded: products.length,
|
|
113
|
+
loading,
|
|
114
|
+
refreshing,
|
|
115
|
+
error,
|
|
116
|
+
isEmpty,
|
|
117
|
+
status,
|
|
118
|
+
params,
|
|
119
|
+
setParams,
|
|
120
|
+
resetParams,
|
|
121
|
+
next,
|
|
122
|
+
prev,
|
|
123
|
+
goToPage,
|
|
124
|
+
loadMore,
|
|
125
|
+
reload,
|
|
126
|
+
reloadQuiet,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The category tree — an ARRAY of root categories with subcategories nested
|
|
132
|
+
* under `children`. One cached call; use it for navigation and for the
|
|
133
|
+
* `category_id` filter on `useProductList`.
|
|
134
|
+
*
|
|
135
|
+
* @returns {{items: Array<object>, loading: boolean, error: object|null, reload: () => void}}
|
|
136
|
+
*/
|
|
137
|
+
export function useCategories() {
|
|
138
|
+
const store = useStorefront();
|
|
139
|
+
const { data, loading, error, reload } = useAsyncData(() => store.listCategories(), [store]);
|
|
140
|
+
return { items: data ?? [], loading, error, reload };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ribbons — an ARRAY of `{ id, name, count }`. Labels like "Best Seller" for
|
|
145
|
+
* cards and the product page, and the `ribbon_id` filter's option list.
|
|
146
|
+
*
|
|
147
|
+
* @returns {{items: Array<object>, loading: boolean, error: object|null, reload: () => void}}
|
|
148
|
+
*/
|
|
149
|
+
export function useRibbons() {
|
|
150
|
+
const store = useStorefront();
|
|
151
|
+
const { data, loading, error, reload } = useAsyncData(() => store.listRibbons(), [store]);
|
|
152
|
+
return { items: data ?? [], loading, error, reload };
|
|
153
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}
|