@base44/app-plugin-commerce 0.2.1 → 0.2.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 +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +355 -99
- package/skills/commerce/install/03-data.md +5 -5
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +68 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- package/src/commerce/storefront/blocks/index.js +0 -44
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Framework-free, dependency-free, no I/O — feed them the `{ product,
|
|
13
13
|
* variations }` pair from `commerce/storefront-catalog` `get-product` (the
|
|
14
|
-
* admin's variation editor can use them too). See
|
|
15
|
-
*
|
|
14
|
+
* admin's variation editor can use them too). See the commerce skill's
|
|
15
|
+
* references/catalog-rendering.md for the UI rules.
|
|
16
16
|
*
|
|
17
17
|
* A `selection` throughout is a plain object keyed by axis key (see
|
|
18
18
|
* `attributeKey`): `{ "<color-attr-id>": "Red", "<size-attr-id>": "M" }`.
|
|
@@ -435,3 +435,50 @@ export function selectionFromParams(product, variations, params) {
|
|
|
435
435
|
}
|
|
436
436
|
return selection;
|
|
437
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A resolved view's axes as a render-ready model for the variant selector *you*
|
|
441
|
+
* write: one entry per axis, one entry per option, with selected / stock /
|
|
442
|
+
* disabled state already derived. All markup and styling are yours; the two
|
|
443
|
+
* rules this model encodes are **one control per axis** (never a flat list of
|
|
444
|
+
* variations) and **an unbuyable option renders disabled, not hidden** — a
|
|
445
|
+
* customer who can't see that a size exists assumes the store doesn't carry it.
|
|
446
|
+
*
|
|
447
|
+
* const { view, pick } = useProduct(slug);
|
|
448
|
+
* variantAxes(view, pick).map(axis =>
|
|
449
|
+
* // axis.name, axis.selectedOption
|
|
450
|
+
* axis.options.map(o =>
|
|
451
|
+
* // o.value, o.selected, o.disabled (don't render clickable),
|
|
452
|
+
* // o.outOfStock (strike through / mark, still visible), o.pick()
|
|
453
|
+
* ));
|
|
454
|
+
*
|
|
455
|
+
* @param {object|null} view from `resolveSelection` / `useProduct().view`
|
|
456
|
+
* @param {(axisKey: string, option: string) => void} [pick] click handler to
|
|
457
|
+
* bind per option (`useProduct().pick`)
|
|
458
|
+
* @returns {Array<{key: string, name: string, selectedOption: string|null,
|
|
459
|
+
* options: Array<{value: string, selected: boolean, state: string,
|
|
460
|
+
* outOfStock: boolean, disabled: boolean, pick: () => void}>}>}
|
|
461
|
+
* `[]` for a product without variants — render nothing.
|
|
462
|
+
*/
|
|
463
|
+
export function variantAxes(view, pick) {
|
|
464
|
+
if (!view?.isVariable) return [];
|
|
465
|
+
return (view.axes ?? []).map((axis) => {
|
|
466
|
+
const selectedOption = view.selection?.[axis.key] || null;
|
|
467
|
+
return {
|
|
468
|
+
key: axis.key,
|
|
469
|
+
name: axis.name,
|
|
470
|
+
selectedOption,
|
|
471
|
+
options: (axis.options ?? []).map((option) => {
|
|
472
|
+
const state = view.availability?.[axis.key]?.[option] ?? OPTION_AVAILABLE;
|
|
473
|
+
return {
|
|
474
|
+
value: option,
|
|
475
|
+
selected: option === selectedOption,
|
|
476
|
+
state,
|
|
477
|
+
outOfStock: state === OPTION_OUT_OF_STOCK,
|
|
478
|
+
disabled: state === OPTION_UNAVAILABLE,
|
|
479
|
+
pick: () => pick?.(axis.key, option),
|
|
480
|
+
};
|
|
481
|
+
}),
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useAddToCart } from "../useProduct";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The buy button, with quantity, stock state and every add-to-cart failure
|
|
6
|
-
* handled.
|
|
7
|
-
*
|
|
8
|
-
* const p = useProduct(slug);
|
|
9
|
-
* <AddToCartBlock product={p} onAdded={() => navigate("/bag")} />
|
|
10
|
-
*
|
|
11
|
-
* Pass the whole `useProduct` result as `product` and it wires itself: the
|
|
12
|
-
* button is gated on `view.purchasable`, the quantity respects
|
|
13
|
-
* `sold_individually` and tracked stock, a rejected add (sold out, stale
|
|
14
|
-
* variant) renders inline instead of leaving the button stuck on "Adding…", and
|
|
15
|
-
* a stale-variant error reloads the product.
|
|
16
|
-
*
|
|
17
|
-
* @param {{product: object, className?: string, label?: string,
|
|
18
|
-
* addingLabel?: string, showQuantity?: boolean,
|
|
19
|
-
* onAdded?: (cart: object) => void}} props
|
|
20
|
-
*/
|
|
21
|
-
export function AddToCartBlock({
|
|
22
|
-
product,
|
|
23
|
-
className = "",
|
|
24
|
-
label = "Add to bag",
|
|
25
|
-
addingLabel = "Adding…",
|
|
26
|
-
showQuantity = true,
|
|
27
|
-
onAdded,
|
|
28
|
-
}) {
|
|
29
|
-
const { add, adding, error } = useAddToCart();
|
|
30
|
-
if (!product?.view) return null;
|
|
31
|
-
|
|
32
|
-
const { view, quantity, incQuantity, decQuantity, canIncrease, maxQuantity, reload } = product;
|
|
33
|
-
const soldOut = view.isVariable ? view.complete && !view.purchasable : !view.purchasable;
|
|
34
|
-
|
|
35
|
-
const onClick = async () => {
|
|
36
|
-
const res = await add(view.addToCart, quantity);
|
|
37
|
-
if (res.ok) onAdded?.(res.cart);
|
|
38
|
-
else if (res.error?.shouldReload) reload?.();
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<div data-commerce="add-to-cart-block" className={className}>
|
|
43
|
-
{showQuantity && maxQuantity > 1 && (
|
|
44
|
-
<div className="mb-3 flex items-center gap-1">
|
|
45
|
-
<button
|
|
46
|
-
type="button"
|
|
47
|
-
data-commerce="product-quantity-decrease"
|
|
48
|
-
onClick={decQuantity}
|
|
49
|
-
disabled={quantity <= 1}
|
|
50
|
-
aria-label="Decrease quantity"
|
|
51
|
-
className="h-9 w-9 border border-border disabled:opacity-40"
|
|
52
|
-
>
|
|
53
|
-
−
|
|
54
|
-
</button>
|
|
55
|
-
<span data-commerce="product-quantity" className="w-10 text-center text-sm">{quantity}</span>
|
|
56
|
-
<button
|
|
57
|
-
type="button"
|
|
58
|
-
data-commerce="product-quantity-increase"
|
|
59
|
-
onClick={incQuantity}
|
|
60
|
-
disabled={!canIncrease}
|
|
61
|
-
aria-label="Increase quantity"
|
|
62
|
-
className="h-9 w-9 border border-border disabled:opacity-40"
|
|
63
|
-
>
|
|
64
|
-
+
|
|
65
|
-
</button>
|
|
66
|
-
</div>
|
|
67
|
-
)}
|
|
68
|
-
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
data-commerce="add-to-cart"
|
|
72
|
-
onClick={onClick}
|
|
73
|
-
disabled={!view.purchasable || adding}
|
|
74
|
-
className="w-full bg-primary px-6 py-3 text-sm text-primary-foreground disabled:opacity-50"
|
|
75
|
-
>
|
|
76
|
-
{adding ? addingLabel : soldOut ? "Sold out" : !view.complete ? "Select options" : label}
|
|
77
|
-
</button>
|
|
78
|
-
|
|
79
|
-
{error && (
|
|
80
|
-
<p data-commerce="add-to-cart-error" role="alert" className="mt-2 text-sm text-destructive">
|
|
81
|
-
{error.message}
|
|
82
|
-
</p>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useAddressForm } from "../useAddressForm";
|
|
3
|
-
import { useCheckoutContext } from "../useCheckout";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The checkout address form, rendered from the field spec — so `state` is
|
|
7
|
-
* collected (rates and taxes match on country + state), the country select
|
|
8
|
-
* never maps a null list, and browser autofill works.
|
|
9
|
-
*
|
|
10
|
-
* <AddressFieldsBlock /> // billing
|
|
11
|
-
* <AddressFieldsBlock which="shipping" /> // a separate delivery address
|
|
12
|
-
*
|
|
13
|
-
* Needs a `<CheckoutProvider>` above it (`<CheckoutBlock/>` supplies one).
|
|
14
|
-
*
|
|
15
|
-
* @param {{which?: "billing"|"shipping", className?: string, legend?: string,
|
|
16
|
-
* includeCompany?: boolean, includePhone?: boolean}} props
|
|
17
|
-
*/
|
|
18
|
-
export function AddressFieldsBlock({
|
|
19
|
-
which = "billing",
|
|
20
|
-
className = "",
|
|
21
|
-
legend,
|
|
22
|
-
includeCompany = false,
|
|
23
|
-
includePhone = true,
|
|
24
|
-
}) {
|
|
25
|
-
const { fields, set, countriesLoading } = useAddressForm(which, { includeCompany, includePhone });
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<fieldset data-commerce={`address-${which}`} className={className}>
|
|
29
|
-
{legend && <legend className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">{legend}</legend>}
|
|
30
|
-
<div className="grid grid-cols-2 gap-3">
|
|
31
|
-
{fields.map((f) => (
|
|
32
|
-
<label
|
|
33
|
-
key={f.key}
|
|
34
|
-
className={f.colSpan === 2 ? "col-span-2" : "col-span-2 sm:col-span-1"}
|
|
35
|
-
>
|
|
36
|
-
<span className="block text-xs text-muted-foreground">
|
|
37
|
-
{f.label}
|
|
38
|
-
{f.required && <span aria-hidden> *</span>}
|
|
39
|
-
</span>
|
|
40
|
-
{f.type === "select" ? (
|
|
41
|
-
<select
|
|
42
|
-
data-commerce={`address-field-${f.key}`}
|
|
43
|
-
value={f.value}
|
|
44
|
-
required={f.required}
|
|
45
|
-
onChange={(e) => set(f.key, e.target.value)}
|
|
46
|
-
autoComplete={f.autoComplete}
|
|
47
|
-
disabled={f.key === "country" && countriesLoading}
|
|
48
|
-
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
49
|
-
>
|
|
50
|
-
<option value="">
|
|
51
|
-
{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label.toLowerCase()}`}
|
|
52
|
-
</option>
|
|
53
|
-
{f.options.map((o) => (
|
|
54
|
-
<option key={o.value} value={o.value}>
|
|
55
|
-
{o.label}
|
|
56
|
-
</option>
|
|
57
|
-
))}
|
|
58
|
-
</select>
|
|
59
|
-
) : (
|
|
60
|
-
<input
|
|
61
|
-
data-commerce={`address-field-${f.key}`}
|
|
62
|
-
type={f.type}
|
|
63
|
-
value={f.value}
|
|
64
|
-
required={f.required}
|
|
65
|
-
onChange={(e) => set(f.key, e.target.value)}
|
|
66
|
-
autoComplete={f.autoComplete}
|
|
67
|
-
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
68
|
-
/>
|
|
69
|
-
)}
|
|
70
|
-
{f.error && (
|
|
71
|
-
<span role="alert" className="mt-1 block text-xs text-destructive">
|
|
72
|
-
{f.error}
|
|
73
|
-
</span>
|
|
74
|
-
)}
|
|
75
|
-
</label>
|
|
76
|
-
))}
|
|
77
|
-
</div>
|
|
78
|
-
{which === "billing" && <ShipToDifferentToggle />}
|
|
79
|
-
</fieldset>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function ShipToDifferentToggle() {
|
|
84
|
-
const { shipToDifferent, setShipToDifferent } = useCheckoutContext();
|
|
85
|
-
return (
|
|
86
|
-
<label className="mt-3 flex items-center gap-2 text-sm">
|
|
87
|
-
<input
|
|
88
|
-
type="checkbox"
|
|
89
|
-
data-commerce="ship-to-different"
|
|
90
|
-
checked={shipToDifferent}
|
|
91
|
-
onChange={(e) => setShipToDifferent(e.target.checked)}
|
|
92
|
-
/>
|
|
93
|
-
Deliver to a different address
|
|
94
|
-
</label>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Category breadcrumbs for a product page.
|
|
5
|
-
*
|
|
6
|
-
* <BreadcrumbsBlock categories={categories} current={product.name} />
|
|
7
|
-
*
|
|
8
|
-
* Ribbons are deliberately NOT breadcrumbs — they are labels ("Best Seller"),
|
|
9
|
-
* not places in the catalog. Pass `renderLink` to use your router's `<Link>`
|
|
10
|
-
* instead of a plain anchor.
|
|
11
|
-
*
|
|
12
|
-
* @param {{categories?: Array<object>, current?: string, className?: string,
|
|
13
|
-
* homeLabel?: string, homeHref?: string, categoryHref?: (c: object) => string,
|
|
14
|
-
* renderLink?: (o: {href: string, label: string}) => React.ReactNode}} props
|
|
15
|
-
*/
|
|
16
|
-
export function BreadcrumbsBlock({
|
|
17
|
-
categories = [],
|
|
18
|
-
current,
|
|
19
|
-
className = "",
|
|
20
|
-
homeLabel = "Home",
|
|
21
|
-
homeHref = "/",
|
|
22
|
-
categoryHref = (c) => `/collection?category_id=${c.id}`,
|
|
23
|
-
renderLink,
|
|
24
|
-
}) {
|
|
25
|
-
const crumbs = [
|
|
26
|
-
{ href: homeHref, label: homeLabel },
|
|
27
|
-
...categories.map((c) => ({ href: categoryHref(c), label: c.name })),
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const link = ({ href, label }) =>
|
|
31
|
-
renderLink ? (
|
|
32
|
-
renderLink({ href, label })
|
|
33
|
-
) : (
|
|
34
|
-
<a href={href} className="underline-offset-2 hover:underline">
|
|
35
|
-
{label}
|
|
36
|
-
</a>
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<nav data-commerce="breadcrumbs" aria-label="Breadcrumb" className={`text-xs text-muted-foreground ${className}`}>
|
|
41
|
-
<ol className="flex flex-wrap items-center gap-1.5">
|
|
42
|
-
{crumbs.map((c) => (
|
|
43
|
-
<li key={`${c.href}-${c.label}`} className="flex items-center gap-1.5">
|
|
44
|
-
{link(c)}
|
|
45
|
-
<span aria-hidden>/</span>
|
|
46
|
-
</li>
|
|
47
|
-
))}
|
|
48
|
-
{current && <li aria-current="page">{current}</li>}
|
|
49
|
-
</ol>
|
|
50
|
-
</nav>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCart } from "../StorefrontProvider";
|
|
3
|
-
import { useFormatMoney } from "../StorefrontProvider";
|
|
4
|
-
import { QuantityStepper } from "./QuantityStepper";
|
|
5
|
-
import { CouponFieldBlock } from "./CouponFieldBlock";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The bag's line items: image, name, variant label, quantity stepper, line
|
|
9
|
-
* total — plus the notices that say what auto-dropped and why, and the coupon
|
|
10
|
-
* field.
|
|
11
|
-
*
|
|
12
|
-
* <CartLinesBlock />
|
|
13
|
-
*
|
|
14
|
-
* Branches on the cart's `status`, so it never flashes an empty state while the
|
|
15
|
-
* cart is still loading. Pass `slots.empty` / `slots.loading` to brand those
|
|
16
|
-
* two states, or `renderLine` to take over a row entirely.
|
|
17
|
-
*
|
|
18
|
-
* **The coupon field is on by default** (`showCoupon`). A store whose codes have
|
|
19
|
-
* nowhere to be typed cannot honour its own discounts, and that has been the
|
|
20
|
-
* most-repeated omission in hand-built storefronts — so the default is the safe
|
|
21
|
-
* one. Turn it off for a store that has no coupons and doesn't want the field,
|
|
22
|
-
* or when your checkout carries the only one.
|
|
23
|
-
*
|
|
24
|
-
* @param {{className?: string, showNotices?: boolean, showCoupon?: boolean,
|
|
25
|
-
* slots?: {empty?: React.ReactNode, loading?: React.ReactNode},
|
|
26
|
-
* renderLine?: (line: object) => React.ReactNode}} props
|
|
27
|
-
*/
|
|
28
|
-
export function CartLinesBlock({
|
|
29
|
-
className = "",
|
|
30
|
-
showNotices = true,
|
|
31
|
-
showCoupon = true,
|
|
32
|
-
slots = {},
|
|
33
|
-
renderLine,
|
|
34
|
-
}) {
|
|
35
|
-
const { status, lines, notices } = useCart();
|
|
36
|
-
const formatMoney = useFormatMoney();
|
|
37
|
-
|
|
38
|
-
if (status === "loading") {
|
|
39
|
-
return slots.loading ?? <p data-commerce="cart-loading" className="text-sm text-muted-foreground">Loading your bag…</p>;
|
|
40
|
-
}
|
|
41
|
-
if (status === "empty") {
|
|
42
|
-
return slots.empty ?? <p data-commerce="cart-empty" className="text-sm text-muted-foreground">Your bag is empty.</p>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<div data-commerce="cart-lines" className={className}>
|
|
47
|
-
{showNotices && notices.length > 0 && (
|
|
48
|
-
<ul data-commerce="cart-notices" className="mb-4 space-y-1">
|
|
49
|
-
{notices.map((n, i) => (
|
|
50
|
-
<li key={`${n.code}-${i}`} role="status" className="text-xs text-muted-foreground">
|
|
51
|
-
{n.message}
|
|
52
|
-
</li>
|
|
53
|
-
))}
|
|
54
|
-
</ul>
|
|
55
|
-
)}
|
|
56
|
-
|
|
57
|
-
<ul className="divide-y divide-border">
|
|
58
|
-
{lines.map((line) =>
|
|
59
|
-
renderLine ? (
|
|
60
|
-
<React.Fragment key={line.item_key}>{renderLine(line)}</React.Fragment>
|
|
61
|
-
) : (
|
|
62
|
-
<li
|
|
63
|
-
key={line.item_key}
|
|
64
|
-
data-commerce="line-item"
|
|
65
|
-
className="grid grid-cols-[64px_1fr_auto] items-start gap-4 py-4"
|
|
66
|
-
>
|
|
67
|
-
{line.image ? (
|
|
68
|
-
<img
|
|
69
|
-
src={line.image.src}
|
|
70
|
-
alt={line.image.alt}
|
|
71
|
-
className="aspect-square w-16 object-cover"
|
|
72
|
-
loading="lazy"
|
|
73
|
-
/>
|
|
74
|
-
) : (
|
|
75
|
-
<div aria-hidden className="aspect-square w-16 bg-muted" />
|
|
76
|
-
)}
|
|
77
|
-
<div className="min-w-0">
|
|
78
|
-
<p data-commerce="line-name" className="truncate text-sm">{line.name}</p>
|
|
79
|
-
{line.attributesLabel && (
|
|
80
|
-
<p data-commerce="line-attributes" className="mt-0.5 text-xs text-muted-foreground">
|
|
81
|
-
{line.attributesLabel}
|
|
82
|
-
</p>
|
|
83
|
-
)}
|
|
84
|
-
{!line.purchasable && line.unavailableReason && (
|
|
85
|
-
<p role="alert" className="mt-1 text-xs text-destructive">{line.unavailableReason}</p>
|
|
86
|
-
)}
|
|
87
|
-
<QuantityStepper line={line} className="mt-2" />
|
|
88
|
-
</div>
|
|
89
|
-
<p data-commerce="line-total" className="text-sm">{formatMoney(line.total)}</p>
|
|
90
|
-
</li>
|
|
91
|
-
),
|
|
92
|
-
)}
|
|
93
|
-
</ul>
|
|
94
|
-
|
|
95
|
-
{showCoupon && <CouponFieldBlock className="mt-6" />}
|
|
96
|
-
</div>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { CheckoutProvider, useCheckoutContext } from "../useCheckout";
|
|
3
|
-
import { PaymentMethodPicker, ShippingMethodPicker } from "../pickers";
|
|
4
|
-
import { useCart, useFormatMoney } from "../StorefrontProvider";
|
|
5
|
-
import { useCheckoutBlockers } from "../useTotalsLines";
|
|
6
|
-
import { AddressFieldsBlock } from "./AddressFieldsBlock";
|
|
7
|
-
import { CouponFieldBlock } from "./CouponFieldBlock";
|
|
8
|
-
import { TotalsBlock } from "./TotalsBlock";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The whole checkout: address → delivery → payment → summary → place order.
|
|
12
|
-
* It supplies its own `<CheckoutProvider>`, so this is a complete page body:
|
|
13
|
-
*
|
|
14
|
-
* export default function Checkout() {
|
|
15
|
-
* return <main className="mx-auto max-w-3xl px-6 py-16">
|
|
16
|
-
* <h1 className="font-heading text-5xl">Checkout</h1>
|
|
17
|
-
* <CheckoutBlock />
|
|
18
|
-
* </main>;
|
|
19
|
-
* }
|
|
20
|
-
*
|
|
21
|
-
* Correct with zero props in a default-seeded store (one manual gateway, one
|
|
22
|
-
* shipping rate), and every branch a checkout must handle is already wired:
|
|
23
|
-
* shipping recalculates from the address, a single shipping/payment option
|
|
24
|
-
* reads as selected, a store with no enabled gateway says so instead of
|
|
25
|
-
* rendering a dead button, the place-order gate explains itself, and an offline
|
|
26
|
-
* order's payment instructions are shown on the confirmation.
|
|
27
|
-
*
|
|
28
|
-
* Restyle it with `className`, replace one region with `slots`, or drop to the
|
|
29
|
-
* hooks (`useCheckout`, `useAddressForm`, the pickers, `useTotalsLines`) when
|
|
30
|
-
* the design needs different structure — this component is nothing but a
|
|
31
|
-
* composition of them.
|
|
32
|
-
*
|
|
33
|
-
* @param {{className?: string, showCoupon?: boolean, options?: object,
|
|
34
|
-
* onPlaced?: (result: object) => void, orderReceivedPath?: string,
|
|
35
|
-
* slots?: {address?, shipping?, payment?, summary?, submit?, empty?}}} props
|
|
36
|
-
* `onPlaced` overrides the default post-order behaviour (navigating to
|
|
37
|
-
* `orderReceivedPath` with the order's id + key, where `<OrderReceivedBlock/>`
|
|
38
|
-
* renders the confirmation and any payment instructions).
|
|
39
|
-
*/
|
|
40
|
-
export function CheckoutBlock({
|
|
41
|
-
className = "",
|
|
42
|
-
showCoupon = true,
|
|
43
|
-
options,
|
|
44
|
-
onPlaced,
|
|
45
|
-
orderReceivedPath = "/order-received",
|
|
46
|
-
slots = {},
|
|
47
|
-
}) {
|
|
48
|
-
return (
|
|
49
|
-
<CheckoutProvider options={options}>
|
|
50
|
-
<CheckoutBody
|
|
51
|
-
className={className}
|
|
52
|
-
showCoupon={showCoupon}
|
|
53
|
-
onPlaced={onPlaced}
|
|
54
|
-
orderReceivedPath={orderReceivedPath}
|
|
55
|
-
slots={slots}
|
|
56
|
-
/>
|
|
57
|
-
</CheckoutProvider>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function CheckoutBody({ className, showCoupon, onPlaced, orderReceivedPath, slots }) {
|
|
62
|
-
const { status } = useCart();
|
|
63
|
-
const checkout = useCheckoutContext();
|
|
64
|
-
|
|
65
|
-
if (status === "loading") {
|
|
66
|
-
return <p data-commerce="checkout-loading" className="text-sm text-muted-foreground">Loading…</p>;
|
|
67
|
-
}
|
|
68
|
-
if (status === "empty") {
|
|
69
|
-
return (
|
|
70
|
-
slots.empty ?? (
|
|
71
|
-
<p data-commerce="checkout-empty" className="text-sm text-muted-foreground">
|
|
72
|
-
Your bag is empty — add something before checking out.
|
|
73
|
-
</p>
|
|
74
|
-
)
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
<div data-commerce="checkout" className={`grid gap-10 md:grid-cols-[1fr_360px] ${className}`}>
|
|
80
|
-
<div className="space-y-8">
|
|
81
|
-
{slots.address ?? <AddressFieldsBlock legend="Your details" />}
|
|
82
|
-
{checkout.shipToDifferent && <AddressFieldsBlock which="shipping" legend="Delivery address" />}
|
|
83
|
-
{slots.shipping ?? <ShippingStep />}
|
|
84
|
-
{slots.payment ?? <PaymentStep />}
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<aside className="space-y-6">
|
|
88
|
-
{slots.summary ?? (
|
|
89
|
-
<>
|
|
90
|
-
{showCoupon && <CouponFieldBlock />}
|
|
91
|
-
<TotalsBlock />
|
|
92
|
-
</>
|
|
93
|
-
)}
|
|
94
|
-
{slots.submit ?? <PlaceOrder onPlaced={onPlaced} orderReceivedPath={orderReceivedPath} />}
|
|
95
|
-
</aside>
|
|
96
|
-
</div>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function ShippingStep() {
|
|
101
|
-
const formatMoney = useFormatMoney();
|
|
102
|
-
return (
|
|
103
|
-
<ShippingMethodPicker>
|
|
104
|
-
{({ status, methods, chosen, choose, mustChoose, single, syncing }) => (
|
|
105
|
-
<fieldset data-commerce="shipping-step" className={syncing ? "opacity-60" : ""}>
|
|
106
|
-
<legend className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">Delivery</legend>
|
|
107
|
-
|
|
108
|
-
{status === "missing_address" && (
|
|
109
|
-
<p className="text-sm text-muted-foreground">
|
|
110
|
-
Delivery options appear once your address is entered.
|
|
111
|
-
</p>
|
|
112
|
-
)}
|
|
113
|
-
|
|
114
|
-
{status === "none_available" && (
|
|
115
|
-
<p role="alert" className="text-sm text-destructive">
|
|
116
|
-
We don't deliver to that address yet.
|
|
117
|
-
</p>
|
|
118
|
-
)}
|
|
119
|
-
|
|
120
|
-
{mustChoose &&
|
|
121
|
-
methods.map((m) => (
|
|
122
|
-
<label key={m.id} data-commerce="shipping-method" data-commerce-cost={m.cost} className="flex items-center justify-between gap-4 py-1.5 text-sm">
|
|
123
|
-
<span className="flex items-center gap-2">
|
|
124
|
-
<input
|
|
125
|
-
type="radio"
|
|
126
|
-
name="shipping_method"
|
|
127
|
-
value={m.id}
|
|
128
|
-
checked={chosen?.id === m.id}
|
|
129
|
-
onChange={() => choose(m.id)}
|
|
130
|
-
/>
|
|
131
|
-
{m.title}
|
|
132
|
-
</span>
|
|
133
|
-
<span>{formatMoney(m.cost)}</span>
|
|
134
|
-
</label>
|
|
135
|
-
))}
|
|
136
|
-
|
|
137
|
-
{!mustChoose && chosen && (
|
|
138
|
-
<p data-commerce="shipping-method" data-commerce-cost={chosen.cost} className="flex justify-between gap-4 text-sm">
|
|
139
|
-
<span>{chosen.title}</span>
|
|
140
|
-
<span>{formatMoney(chosen.cost)}</span>
|
|
141
|
-
</p>
|
|
142
|
-
)}
|
|
143
|
-
{single && chosen && (
|
|
144
|
-
<p className="mt-1 text-xs text-muted-foreground">The only option for this address.</p>
|
|
145
|
-
)}
|
|
146
|
-
</fieldset>
|
|
147
|
-
)}
|
|
148
|
-
</ShippingMethodPicker>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function PaymentStep() {
|
|
153
|
-
return (
|
|
154
|
-
<PaymentMethodPicker>
|
|
155
|
-
{({ gateways, value, select, selected, single }) => (
|
|
156
|
-
<fieldset data-commerce="payment-step">
|
|
157
|
-
<legend className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">Payment</legend>
|
|
158
|
-
|
|
159
|
-
{gateways.length === 0 && (
|
|
160
|
-
<p role="alert" className="text-sm text-destructive">
|
|
161
|
-
No payment method is available right now — please contact us to complete your order.
|
|
162
|
-
</p>
|
|
163
|
-
)}
|
|
164
|
-
|
|
165
|
-
{!single &&
|
|
166
|
-
gateways.map((g) => (
|
|
167
|
-
<label key={g.slug} data-commerce="payment-method" className="block py-1.5 text-sm">
|
|
168
|
-
<span className="flex items-center gap-2">
|
|
169
|
-
<input
|
|
170
|
-
type="radio"
|
|
171
|
-
name="payment_method"
|
|
172
|
-
value={g.slug}
|
|
173
|
-
checked={value === g.slug}
|
|
174
|
-
onChange={() => select(g.slug)}
|
|
175
|
-
/>
|
|
176
|
-
{g.title}
|
|
177
|
-
</span>
|
|
178
|
-
{g.description && (
|
|
179
|
-
<span className="ml-6 block text-xs text-muted-foreground">{g.description}</span>
|
|
180
|
-
)}
|
|
181
|
-
</label>
|
|
182
|
-
))}
|
|
183
|
-
|
|
184
|
-
{single && selected && (
|
|
185
|
-
<div data-commerce="payment-method" className="text-sm">
|
|
186
|
-
<p>{selected.title}</p>
|
|
187
|
-
{selected.description && (
|
|
188
|
-
<p className="mt-0.5 text-xs text-muted-foreground">{selected.description}</p>
|
|
189
|
-
)}
|
|
190
|
-
</div>
|
|
191
|
-
)}
|
|
192
|
-
</fieldset>
|
|
193
|
-
)}
|
|
194
|
-
</PaymentMethodPicker>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function PlaceOrder({ onPlaced, orderReceivedPath }) {
|
|
199
|
-
const { canPlaceOrder, placing, placeOrder, orderError } = useCheckoutContext();
|
|
200
|
-
const blockers = useCheckoutBlockers();
|
|
201
|
-
|
|
202
|
-
const submit = async () => {
|
|
203
|
-
const res = await placeOrder();
|
|
204
|
-
if (!res.ok) return;
|
|
205
|
-
if (onPlaced) return onPlaced(res.result);
|
|
206
|
-
// An online gateway has already redirected. Everything else lands on the
|
|
207
|
-
// return page, which is also where a manual order's payment instructions
|
|
208
|
-
// are rendered — so the offline default confirms properly too.
|
|
209
|
-
if (!res.result.payment && typeof window !== "undefined") {
|
|
210
|
-
const q = new URLSearchParams({
|
|
211
|
-
order_id: res.result.order_id,
|
|
212
|
-
order_key: res.result.order_key,
|
|
213
|
-
});
|
|
214
|
-
window.location.assign(`${orderReceivedPath}?${q}`);
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<div>
|
|
220
|
-
<button
|
|
221
|
-
type="button"
|
|
222
|
-
data-commerce="place-order"
|
|
223
|
-
onClick={submit}
|
|
224
|
-
disabled={!canPlaceOrder || placing}
|
|
225
|
-
className="w-full bg-primary px-6 py-3 text-sm text-primary-foreground disabled:opacity-50"
|
|
226
|
-
>
|
|
227
|
-
{placing ? "Placing your order…" : "Place order"}
|
|
228
|
-
</button>
|
|
229
|
-
|
|
230
|
-
{orderError && (
|
|
231
|
-
<p data-commerce="order-error" role="alert" className="mt-2 text-sm text-destructive">
|
|
232
|
-
{orderError.message}
|
|
233
|
-
</p>
|
|
234
|
-
)}
|
|
235
|
-
|
|
236
|
-
{!canPlaceOrder && blockers.length > 0 && (
|
|
237
|
-
<ul data-commerce="checkout-blockers" className="mt-2 space-y-1">
|
|
238
|
-
{blockers.map((b) => (
|
|
239
|
-
<li key={b.code} className="text-xs text-muted-foreground">
|
|
240
|
-
{b.message}
|
|
241
|
-
</li>
|
|
242
|
-
))}
|
|
243
|
-
</ul>
|
|
244
|
-
)}
|
|
245
|
-
</div>
|
|
246
|
-
);
|
|
247
|
-
}
|