@base44/app-plugin-commerce 0.2.1 → 0.2.3
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/seed-store/seed-catalog.ts +64 -6
- 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 +381 -94
- package/skills/commerce/install/03-data.md +62 -19
- 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/skills/commerce/references/shipping-and-tax.md +2 -0
- 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/useAddressForm.js +41 -8
- 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 +72 -0
- package/src/commerce/storefront/useProductGallery.js +4 -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
|
@@ -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
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCoupon } from "../useCartLine";
|
|
3
|
-
import { useFormatMoney } from "../StorefrontProvider";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The coupon field. **If the store has any coupons, this has to exist
|
|
7
|
-
* somewhere** — in the cart or the checkout — because codes are admin-only
|
|
8
|
-
* data a customer can only redeem by typing them. A store that seeds coupons
|
|
9
|
-
* and ships no field advertises discounts it cannot honour.
|
|
10
|
-
*
|
|
11
|
-
* <CouponFieldBlock />
|
|
12
|
-
*
|
|
13
|
-
* @param {{className?: string, label?: string, placeholder?: string,
|
|
14
|
-
* applyLabel?: string}} props
|
|
15
|
-
*/
|
|
16
|
-
export function CouponFieldBlock({
|
|
17
|
-
className = "",
|
|
18
|
-
label = "Discount code",
|
|
19
|
-
placeholder = "Enter code",
|
|
20
|
-
applyLabel = "Apply",
|
|
21
|
-
}) {
|
|
22
|
-
const c = useCoupon();
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<div data-commerce="coupon" className={className}>
|
|
26
|
-
<form
|
|
27
|
-
onSubmit={(e) => {
|
|
28
|
-
e.preventDefault();
|
|
29
|
-
c.apply();
|
|
30
|
-
}}
|
|
31
|
-
className="flex items-end gap-2"
|
|
32
|
-
>
|
|
33
|
-
<label className="flex-1">
|
|
34
|
-
<span className="block text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
35
|
-
<input
|
|
36
|
-
data-commerce="coupon-input"
|
|
37
|
-
value={c.code}
|
|
38
|
-
onChange={(e) => c.setCode(e.target.value)}
|
|
39
|
-
placeholder={placeholder}
|
|
40
|
-
autoComplete="off"
|
|
41
|
-
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
42
|
-
/>
|
|
43
|
-
</label>
|
|
44
|
-
<button
|
|
45
|
-
type="submit"
|
|
46
|
-
data-commerce="coupon-apply"
|
|
47
|
-
disabled={c.applying}
|
|
48
|
-
className="border border-border px-4 py-2 text-sm disabled:opacity-50"
|
|
49
|
-
>
|
|
50
|
-
{c.applying ? "Applying…" : applyLabel}
|
|
51
|
-
</button>
|
|
52
|
-
</form>
|
|
53
|
-
|
|
54
|
-
{c.error && (
|
|
55
|
-
<p data-commerce="coupon-error" role="alert" className="mt-1 text-xs text-destructive">
|
|
56
|
-
{c.error.message}
|
|
57
|
-
</p>
|
|
58
|
-
)}
|
|
59
|
-
|
|
60
|
-
{c.applied.length > 0 && (
|
|
61
|
-
<ul data-commerce="coupon-applied" className="mt-2 space-y-1">
|
|
62
|
-
{c.applied.map((a) => (
|
|
63
|
-
<AppliedCoupon key={a.code} coupon={a} onRemove={() => c.remove(a.code)} />
|
|
64
|
-
))}
|
|
65
|
-
</ul>
|
|
66
|
-
)}
|
|
67
|
-
</div>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function AppliedCoupon({ coupon, onRemove }) {
|
|
72
|
-
const formatMoney = useFormatMoney();
|
|
73
|
-
return (
|
|
74
|
-
<li className="flex items-center justify-between gap-3 text-sm">
|
|
75
|
-
<span>
|
|
76
|
-
{coupon.code}
|
|
77
|
-
{coupon.freeShipping ? " · free shipping" : coupon.discount ? ` · −${formatMoney(coupon.discount)}` : ""}
|
|
78
|
-
</span>
|
|
79
|
-
<button type="button" onClick={onRemove} className="text-xs text-muted-foreground underline">
|
|
80
|
-
Remove
|
|
81
|
-
</button>
|
|
82
|
-
</li>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { orderLines } from "@/commerce/utils";
|
|
3
|
-
import { useOrderReturn } from "../useOrderReturn";
|
|
4
|
-
import { useFormatMoney } from "../StorefrontProvider";
|
|
5
|
-
import { useStorefrontSeo, orderSeo } from "../useStorefrontSeo";
|
|
6
|
-
import { TotalsBlock } from "./TotalsBlock";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The `/order-received` page. Mounting this route is **mandatory** — every
|
|
10
|
-
* payment link returns here, and confirming is what marks a card order paid.
|
|
11
|
-
*
|
|
12
|
-
* export default function OrderReceived() {
|
|
13
|
-
* return <main className="mx-auto max-w-2xl px-6 py-20">
|
|
14
|
-
* <OrderReceivedBlock />
|
|
15
|
-
* </main>;
|
|
16
|
-
* }
|
|
17
|
-
*
|
|
18
|
-
* It renders all five states, including the two that hand-written pages drop:
|
|
19
|
-
* **`paymentInstructions` for a manual/offline order** — the default gateway, so
|
|
20
|
-
* without them the store's normal customer is told nothing about how to pay —
|
|
21
|
-
* and the pay-now link for an unpaid card order. The page is also `noindex`, as
|
|
22
|
-
* a receipt carrying an order key should be.
|
|
23
|
-
*
|
|
24
|
-
* @param {{className?: string, slots?: {paid?, unpaid?, cancelled?, loading?},
|
|
25
|
-
* showSummary?: boolean}} props
|
|
26
|
-
*/
|
|
27
|
-
export function OrderReceivedBlock({ className = "", slots = {}, showSummary = true }) {
|
|
28
|
-
const { status, order, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
29
|
-
useStorefrontSeo(orderSeo(order));
|
|
30
|
-
const formatMoney = useFormatMoney();
|
|
31
|
-
|
|
32
|
-
if (status === "loading") {
|
|
33
|
-
return slots.loading ?? <p data-commerce="order-loading" className="text-sm text-muted-foreground">Confirming your order…</p>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (status === "error") {
|
|
37
|
-
return (
|
|
38
|
-
<div data-commerce="order-error" role="alert" className={className}>
|
|
39
|
-
<p className="text-sm text-destructive">{error?.message}</p>
|
|
40
|
-
<button type="button" onClick={() => reload()} className="mt-3 border border-border px-4 py-2 text-sm">
|
|
41
|
-
Try again
|
|
42
|
-
</button>
|
|
43
|
-
</div>
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const lines = orderLines(order);
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<div data-commerce="order-received" data-commerce-status={status} className={className}>
|
|
51
|
-
{status === "paid" && (slots.paid ?? <h2 className="text-2xl">Thank you — your order is confirmed.</h2>)}
|
|
52
|
-
|
|
53
|
-
{status === "unpaid" &&
|
|
54
|
-
(slots.unpaid ?? (
|
|
55
|
-
<div>
|
|
56
|
-
<h2 className="text-2xl">Your order is awaiting payment.</h2>
|
|
57
|
-
{paymentLink?.url && (
|
|
58
|
-
<a
|
|
59
|
-
href={paymentLink.url}
|
|
60
|
-
data-commerce="pay-now"
|
|
61
|
-
className="mt-4 inline-block bg-primary px-6 py-3 text-sm text-primary-foreground"
|
|
62
|
-
>
|
|
63
|
-
Pay now
|
|
64
|
-
</a>
|
|
65
|
-
)}
|
|
66
|
-
</div>
|
|
67
|
-
))}
|
|
68
|
-
|
|
69
|
-
{status === "cancelled" &&
|
|
70
|
-
(slots.cancelled ?? (
|
|
71
|
-
<div>
|
|
72
|
-
<h2 className="text-2xl">Payment was cancelled.</h2>
|
|
73
|
-
{paymentLink?.url && (
|
|
74
|
-
<a href={paymentLink.url} data-commerce="pay-now" className="mt-4 inline-block border border-border px-6 py-3 text-sm">
|
|
75
|
-
Try payment again
|
|
76
|
-
</a>
|
|
77
|
-
)}
|
|
78
|
-
</div>
|
|
79
|
-
))}
|
|
80
|
-
|
|
81
|
-
{order?.order_number && (
|
|
82
|
-
<p data-commerce="order-number" className="mt-2 text-sm text-muted-foreground">
|
|
83
|
-
Order {order.order_number}
|
|
84
|
-
</p>
|
|
85
|
-
)}
|
|
86
|
-
|
|
87
|
-
{/* A manual gateway settles outside the store: these ARE the instructions
|
|
88
|
-
for how the customer pays, so they must never be dropped. */}
|
|
89
|
-
{paymentInstructions && (
|
|
90
|
-
<section data-commerce="payment-instructions" className="mt-6 border border-border p-4">
|
|
91
|
-
<h3 className="text-xs uppercase tracking-wide text-muted-foreground">How to pay</h3>
|
|
92
|
-
{paymentInstructions.description && (
|
|
93
|
-
<p className="mt-2 whitespace-pre-line text-sm">{paymentInstructions.description}</p>
|
|
94
|
-
)}
|
|
95
|
-
{paymentInstructions.account_details && (
|
|
96
|
-
<dl className="mt-3 space-y-1 text-sm">
|
|
97
|
-
{Object.entries(paymentInstructions.account_details).map(([k, v]) => (
|
|
98
|
-
<div key={k} className="flex gap-2">
|
|
99
|
-
<dt className="text-muted-foreground">{k.replace(/_/g, " ")}:</dt>
|
|
100
|
-
<dd>{String(v)}</dd>
|
|
101
|
-
</div>
|
|
102
|
-
))}
|
|
103
|
-
</dl>
|
|
104
|
-
)}
|
|
105
|
-
</section>
|
|
106
|
-
)}
|
|
107
|
-
|
|
108
|
-
{showSummary && lines.length > 0 && (
|
|
109
|
-
<section className="mt-8">
|
|
110
|
-
<ul className="divide-y divide-border">
|
|
111
|
-
{lines.map((l, i) => (
|
|
112
|
-
<li key={l.item_key ?? `${l.name}-${i}`} data-commerce="order-line" className="flex justify-between gap-4 py-3 text-sm">
|
|
113
|
-
<span>
|
|
114
|
-
{l.name}
|
|
115
|
-
{l.attributesLabel && <span className="text-muted-foreground"> · {l.attributesLabel}</span>}
|
|
116
|
-
<span className="text-muted-foreground"> × {l.quantity}</span>
|
|
117
|
-
</span>
|
|
118
|
-
<span>{formatMoney(l.total)}</span>
|
|
119
|
-
</li>
|
|
120
|
-
))}
|
|
121
|
-
</ul>
|
|
122
|
-
<div className="mt-4">
|
|
123
|
-
<TotalsBlock order={order} />
|
|
124
|
-
</div>
|
|
125
|
-
</section>
|
|
126
|
-
)}
|
|
127
|
-
</div>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useProductGallery } from "../useProductGallery";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The product gallery: main image, thumbnail strip, and the active image
|
|
6
|
-
* following the variant selection.
|
|
7
|
-
*
|
|
8
|
-
* <ProductGalleryBlock product={product} view={view} />
|
|
9
|
-
*
|
|
10
|
-
* The image objects, the placeholder case and the follow-the-variant behaviour
|
|
11
|
-
* all come from `useProductGallery`; this adds the minimum markup. Style it
|
|
12
|
-
* with `className`/`imageClassName` (aspect ratio, crossfade, object-fit are
|
|
13
|
-
* yours), or pass `slots.placeholder` for a branded empty state.
|
|
14
|
-
*
|
|
15
|
-
* @param {{product: object, view?: object, className?: string,
|
|
16
|
-
* imageClassName?: string, showThumbnails?: boolean,
|
|
17
|
-
* slots?: {placeholder?: React.ReactNode}}} props
|
|
18
|
-
*/
|
|
19
|
-
export function ProductGalleryBlock({
|
|
20
|
-
product,
|
|
21
|
-
view = null,
|
|
22
|
-
className = "",
|
|
23
|
-
imageClassName = "aspect-[4/5] w-full object-cover",
|
|
24
|
-
showThumbnails = true,
|
|
25
|
-
slots = {},
|
|
26
|
-
}) {
|
|
27
|
-
const g = useProductGallery(product, view);
|
|
28
|
-
|
|
29
|
-
if (!g.hasImages) {
|
|
30
|
-
return (
|
|
31
|
-
slots.placeholder ?? (
|
|
32
|
-
<div data-commerce="gallery-placeholder" aria-hidden className={`${imageClassName} bg-muted ${className}`} />
|
|
33
|
-
)
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<div data-commerce="gallery" className={className}>
|
|
39
|
-
<img
|
|
40
|
-
data-commerce="gallery-image"
|
|
41
|
-
key={g.active.src}
|
|
42
|
-
src={g.active.src}
|
|
43
|
-
alt={g.active.alt || product?.name || ""}
|
|
44
|
-
className={imageClassName}
|
|
45
|
-
/>
|
|
46
|
-
|
|
47
|
-
{showThumbnails && g.images.length > 1 && (
|
|
48
|
-
<div data-commerce="gallery-thumbnails" className="mt-2 flex gap-2 overflow-x-auto">
|
|
49
|
-
{g.images.map((img, i) => (
|
|
50
|
-
<button
|
|
51
|
-
key={img.src}
|
|
52
|
-
type="button"
|
|
53
|
-
data-commerce="gallery-thumbnail"
|
|
54
|
-
aria-label={`View image ${i + 1}`}
|
|
55
|
-
aria-current={i === g.activeIndex}
|
|
56
|
-
onClick={() => g.setActiveIndex(i)}
|
|
57
|
-
className={`shrink-0 border ${i === g.activeIndex ? "border-foreground" : "border-transparent"}`}
|
|
58
|
-
>
|
|
59
|
-
<img src={img.src} alt="" className="h-16 w-14 object-cover" loading="lazy" />
|
|
60
|
-
</button>
|
|
61
|
-
))}
|
|
62
|
-
</div>
|
|
63
|
-
)}
|
|
64
|
-
</div>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A product's descriptive properties as a spec table — Material, Care, Fit.
|
|
5
|
-
*
|
|
6
|
-
* <ProductSpecsBlock product={product} />
|
|
7
|
-
*
|
|
8
|
-
* These live in `product.meta_data` (the admin's *Modifiers* section) and are
|
|
9
|
-
* **not** attributes and not ribbons: they describe the product, they don't
|
|
10
|
-
* select a variant. Hidden keys (leading `_`) are skipped.
|
|
11
|
-
*
|
|
12
|
-
* @param {{product: object, className?: string, title?: string}} props
|
|
13
|
-
*/
|
|
14
|
-
export function ProductSpecsBlock({ product, className = "", title }) {
|
|
15
|
-
const entries = (product?.meta_data ?? []).filter(
|
|
16
|
-
(m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "",
|
|
17
|
-
);
|
|
18
|
-
if (!entries.length) return null;
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<section data-commerce="product-specs" className={className}>
|
|
22
|
-
{title && <h3 className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{title}</h3>}
|
|
23
|
-
<dl className="divide-y divide-border">
|
|
24
|
-
{entries.map((m) => (
|
|
25
|
-
<div key={m.key} className="flex justify-between gap-4 py-2 text-sm">
|
|
26
|
-
<dt className="text-muted-foreground">{String(m.key).replace(/_/g, " ")}</dt>
|
|
27
|
-
<dd className="text-right">{String(m.value)}</dd>
|
|
28
|
-
</div>
|
|
29
|
-
))}
|
|
30
|
-
</dl>
|
|
31
|
-
</section>
|
|
32
|
-
);
|
|
33
|
-
}
|