@base44/app-plugin-commerce 0.1.19 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +25 -22
  2. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  4. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  5. package/base44/shared/commerce/card-payment.stripe.ts +178 -0
  6. package/base44/shared/commerce/scan.ts +1 -1
  7. package/base44/shared/commerce/sequence.ts +1 -1
  8. package/package.json +1 -1
  9. package/scripts/install.js +24 -14
  10. package/skills/commerce/SKILL.md +107 -51
  11. package/skills/commerce/docs/api-admin.md +89 -28
  12. package/skills/commerce/docs/api-storefront.md +113 -126
  13. package/skills/commerce/docs/entities.md +137 -0
  14. package/skills/commerce/install/01-install.md +101 -0
  15. package/skills/commerce/install/02-storefront.md +188 -0
  16. package/skills/commerce/install/03-data.md +162 -0
  17. package/skills/commerce/references/admin-product-form.md +10 -0
  18. package/skills/commerce/references/catalog-rendering.md +110 -0
  19. package/skills/commerce/references/emails.md +49 -12
  20. package/skills/commerce/references/guest-access-security.md +18 -5
  21. package/skills/commerce/references/online-payments.md +50 -149
  22. package/skills/commerce/references/operations.md +52 -0
  23. package/skills/commerce/references/reviews.md +31 -16
  24. package/skills/commerce/references/shipping-and-tax.md +110 -0
  25. package/skills/commerce/references/store-admin-agent.md +21 -0
  26. package/skills/commerce/references/store-settings.md +49 -0
  27. package/src/commerce/admin/README.md +2 -2
  28. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  29. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  30. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  31. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
  32. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
  33. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
  34. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
  35. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
  36. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
  37. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
  38. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
  39. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
  40. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
  41. package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
  42. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
  43. package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
  44. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
  45. package/src/commerce/storefront/blocks/index.js +44 -0
  46. package/src/commerce/storefront/index.js +59 -21
  47. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  48. package/src/commerce/storefront/pickers.jsx +20 -5
  49. package/src/commerce/storefront/useAddressForm.js +96 -0
  50. package/src/commerce/storefront/useCartLine.js +184 -0
  51. package/src/commerce/storefront/useCheckout.jsx +38 -11
  52. package/src/commerce/storefront/useProduct.js +227 -0
  53. package/src/commerce/storefront/useProductGallery.js +74 -0
  54. package/src/commerce/storefront/useProductList.js +153 -0
  55. package/src/commerce/storefront/useProductPrice.js +58 -0
  56. package/src/commerce/storefront/useProductReviews.js +242 -0
  57. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  58. package/src/commerce/storefront/useTotalsLines.js +109 -0
  59. package/src/commerce/utils/address-spec.js +89 -0
  60. package/src/commerce/utils/images.js +45 -0
  61. package/src/commerce/utils/index.js +18 -6
  62. package/src/commerce/utils/price.js +95 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +10 -2
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -495
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -21,6 +21,18 @@ const EMPTY_ADDRESS = Object.freeze({
21
21
  phone: "",
22
22
  });
23
23
 
24
+ /**
25
+ * The gateway a checkout is actually paying with: the customer's pick while it
26
+ * is still an enabled gateway, otherwise the only gateway there is — one option
27
+ * is not a choice. Derived on every render from the current store info, so it
28
+ * follows the data instead of trailing it by an effect.
29
+ */
30
+ function resolvePaymentMethod(gateways, picked) {
31
+ if (!gateways?.length) return "";
32
+ if (picked && gateways.some((g) => g.slug === picked)) return picked;
33
+ return gateways.length === 1 ? gateways[0].slug : "";
34
+ }
35
+
24
36
  /**
25
37
  * useCheckout — the guided checkout state machine. It owns the parts every
26
38
  * checkout must get right, so the page you build is only markup around it:
@@ -38,8 +50,15 @@ const EMPTY_ADDRESS = Object.freeze({
38
50
  * `chosenShippingMethod`), `chosen`, `choice_required` (render
39
51
  * `shippingMethods` and call `chooseShippingMethod(id)`), `missing_address`
40
52
  * (collect the address), `not_needed` (virtual cart — render nothing).
53
+ * `singleShippingMethod` flags the one-option case, and `chosenShippingMethod`
54
+ * is filled for it — a single option is never left unselected.
41
55
  * - **Payment choice.** `paymentMethods` come from store info (their ONLY
42
- * source); a store with exactly one enabled gateway gets it pre-selected.
56
+ * source); a store with exactly one enabled gateway gets it selected
57
+ * (`singlePaymentMethod`, with `selectedGateway` filled) from the first
58
+ * render that has store info. The selection is derived from the current
59
+ * gateway list, not remembered: when store info changes, a gateway that is
60
+ * no longer enabled is dropped and a list that is down to one gateway
61
+ * selects it — the customer's own pick survives as long as it stays enabled.
43
62
  * - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
44
63
  * between the customer and the order — drive the button's disabled state
45
64
  * and the "what's missing" hints from them instead of re-deriving.
@@ -70,7 +89,7 @@ export function useCheckout(options = {}) {
70
89
  const [billing, setBilling] = useState({ ...EMPTY_ADDRESS, email: "" });
71
90
  const [shipping, setShipping] = useState({ ...EMPTY_ADDRESS });
72
91
  const [shipToDifferent, setShipToDifferent] = useState(false);
73
- const [paymentMethod, setPaymentMethod] = useState("");
92
+ const [pickedPaymentMethod, setPaymentMethod] = useState("");
74
93
  const [addressError, setAddressError] = useState(null);
75
94
  const [syncing, setSyncing] = useState(false);
76
95
  const [syncedKey, setSyncedKey] = useState(undefined);
@@ -124,24 +143,30 @@ export function useCheckout(options = {}) {
124
143
  // ── shipping choice (from the shared cart view) ──────────────────────────
125
144
  const shippingStatus = cart?.shipping_status ?? null;
126
145
  const shippingMethods = cart?.available_shipping_methods ?? [];
146
+ const singleShippingMethod = shippingMethods.length === 1;
147
+ // The backend auto-selects when it offers exactly one rate (`auto_selected`)
148
+ // and echoes it back on the cart, so the lookup normally finds it. The
149
+ // fallback covers the seam where a freshly repriced address offers one rate
150
+ // the cart's stored id hasn't caught up with: a single option is selected,
151
+ // and must read as selected, from the moment it is offered.
127
152
  const chosenShippingMethod =
128
- shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ?? null;
153
+ shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ??
154
+ (singleShippingMethod ? shippingMethods[0] : null);
129
155
  const chooseShippingMethod = useCallback(
130
156
  (methodId) => runCart(() => client.chooseShippingMethod(methodId)),
131
157
  [client, runCart],
132
158
  );
133
159
 
134
160
  // ── payment choice (gateways live on store info ONLY) ────────────────────
161
+ // The pick is state; the *method* is derived, so a lone gateway is selected
162
+ // on the very first render that has store info (never a frame of "nothing
163
+ // selected", never a wasted effect pass) and every one of these re-resolves
164
+ // the moment the gateway list changes — a gateway the admin just disabled
165
+ // drops out, and if that leaves exactly one, it takes over immediately.
135
166
  const paymentMethods = info?.payment_gateways ?? null;
136
- useEffect(() => {
137
- if (!paymentMethods) return;
138
- if (paymentMethod && !paymentMethods.some((g) => g.slug === paymentMethod)) {
139
- setPaymentMethod("");
140
- } else if (!paymentMethod && paymentMethods.length === 1) {
141
- setPaymentMethod(paymentMethods[0].slug); // one option is not a choice
142
- }
143
- }, [paymentMethods, paymentMethod]);
167
+ const paymentMethod = resolvePaymentMethod(paymentMethods, pickedPaymentMethod);
144
168
  const selectedGateway = paymentMethods?.find((g) => g.slug === paymentMethod) ?? null;
169
+ const singlePaymentMethod = paymentMethods?.length === 1;
145
170
 
146
171
  // ── the gate ─────────────────────────────────────────────────────────────
147
172
  const missingBilling = missingBillingFields(billing, requiredBillingFields);
@@ -220,12 +245,14 @@ export function useCheckout(options = {}) {
220
245
  shippingStatus,
221
246
  shippingMethods,
222
247
  chosenShippingMethod,
248
+ singleShippingMethod,
223
249
  chooseShippingMethod,
224
250
  // payment choice
225
251
  paymentMethods,
226
252
  paymentMethod,
227
253
  setPaymentMethod,
228
254
  selectedGateway,
255
+ singlePaymentMethod,
229
256
  // the gate + the order
230
257
  blockers,
231
258
  canPlaceOrder,
@@ -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
+ }