@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.
Files changed (71) 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/useAddressForm.js +96 -0
  49. package/src/commerce/storefront/useCartLine.js +184 -0
  50. package/src/commerce/storefront/useProduct.js +227 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +18 -6
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/storefront.js +47 -3
  62. package/src/commerce/utils/totals.js +110 -0
  63. package/src/commerce/utils/variants.js +10 -2
  64. package/skills/commerce/installation-guidelines.md +0 -93
  65. package/skills/commerce/post-installation.md +0 -496
  66. package/skills/commerce/references/limits-and-performance.md +0 -16
  67. package/skills/commerce/references/media-and-downloads.md +0 -4
  68. package/skills/commerce/references/product-render.md +0 -89
  69. package/skills/commerce/references/scheduled-work.md +0 -19
  70. package/skills/commerce/references/storefront-product-page.md +0 -83
  71. 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
+ }