@base44/app-plugin-commerce 0.1.20 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -22
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/shared/commerce/card-payment.stripe.ts +178 -0
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +107 -51
- package/skills/commerce/docs/api-admin.md +89 -28
- package/skills/commerce/docs/api-storefront.md +113 -126
- package/skills/commerce/docs/entities.md +137 -0
- package/skills/commerce/install/01-install.md +101 -0
- package/skills/commerce/install/02-storefront.md +188 -0
- package/skills/commerce/install/03-data.md +162 -0
- package/skills/commerce/references/admin-product-form.md +10 -0
- package/skills/commerce/references/catalog-rendering.md +110 -0
- package/skills/commerce/references/emails.md +49 -12
- package/skills/commerce/references/guest-access-security.md +18 -5
- package/skills/commerce/references/online-payments.md +50 -149
- package/skills/commerce/references/operations.md +52 -0
- package/skills/commerce/references/reviews.md +31 -16
- package/skills/commerce/references/shipping-and-tax.md +110 -0
- package/skills/commerce/references/store-admin-agent.md +21 -0
- package/skills/commerce/references/store-settings.md +49 -0
- package/src/commerce/admin/README.md +2 -2
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
- package/src/commerce/storefront/blocks/index.js +44 -0
- package/src/commerce/storefront/index.js +59 -21
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- package/src/commerce/storefront/useProduct.js +227 -0
- package/src/commerce/storefront/useProductGallery.js +74 -0
- package/src/commerce/storefront/useProductList.js +153 -0
- package/src/commerce/storefront/useProductPrice.js +58 -0
- package/src/commerce/storefront/useProductReviews.js +242 -0
- package/src/commerce/storefront/useStorefrontSeo.js +204 -0
- package/src/commerce/storefront/useTotalsLines.js +109 -0
- package/src/commerce/utils/address-spec.js +89 -0
- package/src/commerce/utils/images.js +45 -0
- package/src/commerce/utils/index.js +18 -6
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +10 -2
- package/skills/commerce/installation-guidelines.md +0 -93
- package/skills/commerce/post-installation.md +0 -496
- package/skills/commerce/references/limits-and-performance.md +0 -16
- package/skills/commerce/references/media-and-downloads.md +0 -4
- package/skills/commerce/references/product-render.md +0 -89
- package/skills/commerce/references/scheduled-work.md +0 -19
- package/skills/commerce/references/storefront-product-page.md +0 -83
- package/skills/commerce/references/webhooks.md +0 -10
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useProductList } from "../useProductList";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A row of products — a featured rail, "new in", upsells beside a product, a
|
|
6
|
+
* few picks under an article.
|
|
7
|
+
*
|
|
8
|
+
* <ProductStripBlock params={{ featured: true, per_page: 4 }}
|
|
9
|
+
* renderCard={(p) => <MyCard key={p.id} product={p} />} />
|
|
10
|
+
*
|
|
11
|
+
* **`renderCard` is required and has no default**: the product card is the most
|
|
12
|
+
* identity-defining component in a storefront, so the kit never ships one. What
|
|
13
|
+
* this block owns is everything around it — the query, the loading state, and
|
|
14
|
+
* the fact that any filter may legitimately match nothing (nobody has starred a
|
|
15
|
+
* product, nothing is on sale), in which case the strip renders nothing at all
|
|
16
|
+
* rather than an empty row with a heading.
|
|
17
|
+
*
|
|
18
|
+
* Give it `products` instead of `params` to render rows you already have (a
|
|
19
|
+
* product page's `upsells`/`crossSells`).
|
|
20
|
+
*
|
|
21
|
+
* @param {{renderCard: (product: object) => React.ReactNode, params?: object,
|
|
22
|
+
* products?: Array<object>, title?: string, className?: string,
|
|
23
|
+
* listClassName?: string, slots?: {loading?: React.ReactNode}}} props
|
|
24
|
+
*/
|
|
25
|
+
export function ProductStripBlock({
|
|
26
|
+
renderCard,
|
|
27
|
+
params,
|
|
28
|
+
products: given,
|
|
29
|
+
title,
|
|
30
|
+
className = "",
|
|
31
|
+
listClassName = "grid grid-cols-2 gap-6 md:grid-cols-4",
|
|
32
|
+
slots = {},
|
|
33
|
+
}) {
|
|
34
|
+
const enabled = !given;
|
|
35
|
+
const list = useProductList(params ?? {}, { perPage: params?.per_page ?? 4 });
|
|
36
|
+
const rows = given ?? (enabled ? list.products : []);
|
|
37
|
+
const loading = enabled && list.loading;
|
|
38
|
+
|
|
39
|
+
if (loading) return slots.loading ?? null;
|
|
40
|
+
// A filter that matched nothing is normal — render nothing, not a bare heading.
|
|
41
|
+
if (!rows.length) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<section data-commerce="product-strip" className={className}>
|
|
45
|
+
{title && <h2 className="mb-4 text-xs uppercase tracking-wide text-muted-foreground">{title}</h2>}
|
|
46
|
+
<ul className={listClassName}>
|
|
47
|
+
{rows.map((p) => (
|
|
48
|
+
<li key={p.id} data-commerce="product-card">
|
|
49
|
+
{renderCard(p)}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</section>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCartLine } from "../useCartLine";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A cart line's quantity control: − / value / + plus Remove, with stock
|
|
6
|
+
* rejections rendered inline and rapid clicks coalesced.
|
|
7
|
+
*
|
|
8
|
+
* <QuantityStepper line={line} />
|
|
9
|
+
*
|
|
10
|
+
* @param {{line: object, className?: string, showRemove?: boolean,
|
|
11
|
+
* removeLabel?: string}} props `line` comes from `useCart().lines`
|
|
12
|
+
*/
|
|
13
|
+
export function QuantityStepper({ line, className = "", showRemove = true, removeLabel = "Remove" }) {
|
|
14
|
+
const l = useCartLine(line);
|
|
15
|
+
if (!line) return null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div data-commerce="quantity" className={className}>
|
|
19
|
+
<div className="flex items-center gap-1">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
data-commerce="quantity-decrease"
|
|
23
|
+
onClick={l.decrease}
|
|
24
|
+
disabled={!l.canDecrease || l.pending}
|
|
25
|
+
aria-label="Decrease quantity"
|
|
26
|
+
className="h-8 w-8 border border-border disabled:opacity-40"
|
|
27
|
+
>
|
|
28
|
+
−
|
|
29
|
+
</button>
|
|
30
|
+
<span data-commerce="quantity-value" aria-live="polite" className="w-10 text-center text-sm">
|
|
31
|
+
{l.quantity}
|
|
32
|
+
</span>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
data-commerce="quantity-increase"
|
|
36
|
+
onClick={l.increase}
|
|
37
|
+
disabled={!l.canIncrease || l.pending}
|
|
38
|
+
aria-label="Increase quantity"
|
|
39
|
+
className="h-8 w-8 border border-border disabled:opacity-40"
|
|
40
|
+
>
|
|
41
|
+
+
|
|
42
|
+
</button>
|
|
43
|
+
{showRemove && (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
data-commerce="line-remove"
|
|
47
|
+
onClick={l.remove}
|
|
48
|
+
disabled={l.pending}
|
|
49
|
+
className="ml-3 text-xs uppercase tracking-wide text-muted-foreground underline disabled:opacity-40"
|
|
50
|
+
>
|
|
51
|
+
{removeLabel}
|
|
52
|
+
</button>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
{l.error && (
|
|
56
|
+
<p data-commerce="quantity-error" role="alert" className="mt-1 text-xs text-destructive">
|
|
57
|
+
{l.error.message}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useProductReviews } from "../useProductReviews";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reviews section: the list, the aggregate rating, and the submit form —
|
|
6
|
+
* including the store's policy and the right confirmation copy.
|
|
7
|
+
*
|
|
8
|
+
* <ReviewsBlock product={product} /> // anyone with an email
|
|
9
|
+
* <ReviewsBlock product={product} policy="login" user={user} />
|
|
10
|
+
* <ReviewsBlock product={product} policy="verified_buyers" user={user} />
|
|
11
|
+
*
|
|
12
|
+
* The backend ships complete; what this adds is the whole UI contract that used
|
|
13
|
+
* to be hand-rolled: field-level validation matching the server's error codes,
|
|
14
|
+
* the email field hidden for a signed-in visitor (the session's email wins
|
|
15
|
+
* server-side anyway), paging, a refresh after an auto-approved submission, and
|
|
16
|
+
* confirmation text **taken from the response** — a store with auto-approval on
|
|
17
|
+
* is told "published", not "awaiting approval".
|
|
18
|
+
*
|
|
19
|
+
* @param {{product: object, policy?: "open"|"login"|"verified_buyers",
|
|
20
|
+
* user?: object|null, requireRating?: boolean, className?: string,
|
|
21
|
+
* title?: string, showForm?: boolean,
|
|
22
|
+
* slots?: {blocked?: React.ReactNode}}} props
|
|
23
|
+
*/
|
|
24
|
+
export function ReviewsBlock({
|
|
25
|
+
product,
|
|
26
|
+
policy = "open",
|
|
27
|
+
user = null,
|
|
28
|
+
requireRating = false,
|
|
29
|
+
className = "",
|
|
30
|
+
title = "Reviews",
|
|
31
|
+
showForm = true,
|
|
32
|
+
slots = {},
|
|
33
|
+
}) {
|
|
34
|
+
const r = useProductReviews(product, { policy, user, requireRating });
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section data-commerce="reviews" className={className}>
|
|
38
|
+
<header className="flex items-baseline justify-between gap-4">
|
|
39
|
+
<h2 className="text-xs uppercase tracking-wide text-muted-foreground">{title}</h2>
|
|
40
|
+
{r.ratingCount > 0 && (
|
|
41
|
+
<p data-commerce="reviews-summary" className="text-sm">
|
|
42
|
+
{Number(r.averageRating).toFixed(1)} / 5
|
|
43
|
+
<span className="text-muted-foreground"> · {r.ratingCount} review{r.ratingCount === 1 ? "" : "s"}</span>
|
|
44
|
+
</p>
|
|
45
|
+
)}
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
{r.loading ? (
|
|
49
|
+
<p className="mt-4 text-sm text-muted-foreground">Loading reviews…</p>
|
|
50
|
+
) : r.items.length === 0 ? (
|
|
51
|
+
<p data-commerce="reviews-empty" className="mt-4 text-sm text-muted-foreground">
|
|
52
|
+
No reviews yet.
|
|
53
|
+
</p>
|
|
54
|
+
) : (
|
|
55
|
+
<ul className="mt-4 divide-y divide-border">
|
|
56
|
+
{r.items.map((review) => (
|
|
57
|
+
<li key={review.id} data-commerce="review" className="py-4">
|
|
58
|
+
<p className="text-sm">
|
|
59
|
+
<span className="font-medium">{review.reviewer}</span>
|
|
60
|
+
{review.rating ? <span className="text-muted-foreground"> · {review.rating}/5</span> : null}
|
|
61
|
+
{review.verified && (
|
|
62
|
+
<span data-commerce="review-verified" className="text-muted-foreground"> · Verified buyer</span>
|
|
63
|
+
)}
|
|
64
|
+
</p>
|
|
65
|
+
<p className="mt-1 whitespace-pre-line text-sm">{review.review}</p>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{r.hasNext && (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
data-commerce="reviews-more"
|
|
75
|
+
onClick={r.loadMore}
|
|
76
|
+
className="mt-4 border border-border px-4 py-2 text-sm"
|
|
77
|
+
>
|
|
78
|
+
Load more reviews
|
|
79
|
+
</button>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{showForm && (
|
|
83
|
+
<div className="mt-8">
|
|
84
|
+
{r.submitted ? (
|
|
85
|
+
<p data-commerce="review-submitted" role="status" className="text-sm">{r.message}</p>
|
|
86
|
+
) : r.reviewBlockedReason ? (
|
|
87
|
+
(slots.blocked ?? (
|
|
88
|
+
<p data-commerce="review-blocked" className="text-sm text-muted-foreground">
|
|
89
|
+
{r.reviewBlockedReason === "login_required"
|
|
90
|
+
? "Sign in to write a review."
|
|
91
|
+
: "Only verified buyers can review this product."}
|
|
92
|
+
</p>
|
|
93
|
+
))
|
|
94
|
+
) : (
|
|
95
|
+
<ReviewForm r={r} />
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</section>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ReviewForm({ r }) {
|
|
104
|
+
const field = (key, label, node) => (
|
|
105
|
+
<label className="block">
|
|
106
|
+
<span className="block text-xs text-muted-foreground">{label}</span>
|
|
107
|
+
{node}
|
|
108
|
+
{r.fieldErrors[key] && (
|
|
109
|
+
<span role="alert" className="mt-1 block text-xs text-destructive">{r.fieldErrors[key]}</span>
|
|
110
|
+
)}
|
|
111
|
+
</label>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<form
|
|
116
|
+
data-commerce="review-form"
|
|
117
|
+
onSubmit={(e) => {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
r.submit();
|
|
120
|
+
}}
|
|
121
|
+
className="space-y-3"
|
|
122
|
+
>
|
|
123
|
+
<h3 className="text-xs uppercase tracking-wide text-muted-foreground">Write a review</h3>
|
|
124
|
+
|
|
125
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
126
|
+
{field(
|
|
127
|
+
"reviewer",
|
|
128
|
+
"Your name",
|
|
129
|
+
<input
|
|
130
|
+
data-commerce="review-name"
|
|
131
|
+
value={r.form.reviewer}
|
|
132
|
+
onChange={(e) => r.setField("reviewer", e.target.value)}
|
|
133
|
+
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
134
|
+
/>,
|
|
135
|
+
)}
|
|
136
|
+
{r.requiresEmail &&
|
|
137
|
+
field(
|
|
138
|
+
"email",
|
|
139
|
+
"Email",
|
|
140
|
+
<input
|
|
141
|
+
data-commerce="review-email"
|
|
142
|
+
type="email"
|
|
143
|
+
value={r.form.email}
|
|
144
|
+
onChange={(e) => r.setField("email", e.target.value)}
|
|
145
|
+
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
146
|
+
/>,
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{field(
|
|
151
|
+
"rating",
|
|
152
|
+
"Rating",
|
|
153
|
+
<select
|
|
154
|
+
data-commerce="review-rating"
|
|
155
|
+
value={r.form.rating}
|
|
156
|
+
onChange={(e) => r.setField("rating", Number(e.target.value))}
|
|
157
|
+
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm sm:w-32"
|
|
158
|
+
>
|
|
159
|
+
{[5, 4, 3, 2, 1].map((n) => (
|
|
160
|
+
<option key={n} value={n}>{n} / 5</option>
|
|
161
|
+
))}
|
|
162
|
+
</select>,
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{field(
|
|
166
|
+
"review",
|
|
167
|
+
"Your review",
|
|
168
|
+
<textarea
|
|
169
|
+
data-commerce="review-text"
|
|
170
|
+
rows={4}
|
|
171
|
+
value={r.form.review}
|
|
172
|
+
onChange={(e) => r.setField("review", e.target.value)}
|
|
173
|
+
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
174
|
+
/>,
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{r.fieldErrors.form && (
|
|
178
|
+
<p role="alert" className="text-xs text-destructive">{r.fieldErrors.form}</p>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
<button
|
|
182
|
+
type="submit"
|
|
183
|
+
data-commerce="review-submit"
|
|
184
|
+
disabled={!r.valid || r.submitting}
|
|
185
|
+
className="border border-border px-4 py-2 text-sm disabled:opacity-50"
|
|
186
|
+
>
|
|
187
|
+
{r.submitting ? "Submitting…" : "Submit review"}
|
|
188
|
+
</button>
|
|
189
|
+
</form>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useTotalsLines } from "../useTotalsLines";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The order summary — subtotal, discount, shipping, tax, total.
|
|
6
|
+
*
|
|
7
|
+
* <TotalsBlock /> // the shared cart
|
|
8
|
+
* <TotalsBlock order={order} /> // a placed order
|
|
9
|
+
*
|
|
10
|
+
* Renders every line the store actually has, which is the point: a hand-written
|
|
11
|
+
* summary omits discount and tax (they are usually zero) and then stops adding
|
|
12
|
+
* up the day a coupon or a tax rate exists.
|
|
13
|
+
*
|
|
14
|
+
* @param {{order?: object, className?: string, showZero?: boolean,
|
|
15
|
+
* labels?: Record<string, string>, renderLine?: (line: object) => React.ReactNode}} props
|
|
16
|
+
*/
|
|
17
|
+
export function TotalsBlock({ order, className = "", showZero = false, labels, renderLine }) {
|
|
18
|
+
const lines = useTotalsLines(order);
|
|
19
|
+
const visible = lines.filter((l) => showZero || !l.hidden);
|
|
20
|
+
if (!visible.length) return null;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div data-commerce="totals" className={`space-y-2 ${className}`}>
|
|
24
|
+
{visible.map((line) => {
|
|
25
|
+
const l = labels?.[line.key] ? { ...line, label: labels[line.key] } : line;
|
|
26
|
+
if (renderLine) return <React.Fragment key={l.key}>{renderLine(l)}</React.Fragment>;
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
key={l.key}
|
|
30
|
+
data-commerce={`totals-${l.key}`}
|
|
31
|
+
className={`flex items-baseline justify-between gap-4 ${
|
|
32
|
+
l.emphasis ? "border-t border-border pt-2 text-base font-medium" : "text-sm"
|
|
33
|
+
}`}
|
|
34
|
+
>
|
|
35
|
+
<span className={l.emphasis ? "" : "text-muted-foreground"}>{l.label}</span>
|
|
36
|
+
<span data-commerce-value={l.amount}>{l.formatted}</span>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
})}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { OPTION_OUT_OF_STOCK, OPTION_UNAVAILABLE } from "@/commerce/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One control per attribute axis — Size, Color — with availability shown rather
|
|
6
|
+
* than hidden.
|
|
7
|
+
*
|
|
8
|
+
* const { view, pick } = useProduct(slug);
|
|
9
|
+
* <VariantSelectorBlock view={view} onPick={pick} />
|
|
10
|
+
*
|
|
11
|
+
* The rule it encodes: **a selector per axis, never a list of variations**, and
|
|
12
|
+
* an option that isn't buyable is rendered *disabled*, not removed — a customer
|
|
13
|
+
* who can't see that a size exists assumes the store doesn't carry it. Pass
|
|
14
|
+
* `renderOption` for swatches or any other option UI.
|
|
15
|
+
*
|
|
16
|
+
* @param {{view: object, onPick: (axisKey: string, option: string) => void,
|
|
17
|
+
* className?: string,
|
|
18
|
+
* renderOption?: (o: {axis: object, option: string, selected: boolean,
|
|
19
|
+
* state: string, disabled: boolean, pick: () => void}) => React.ReactNode}} props
|
|
20
|
+
*/
|
|
21
|
+
export function VariantSelectorBlock({ view, onPick, className = "", renderOption }) {
|
|
22
|
+
if (!view?.isVariable || !view.axes?.length) return null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div data-commerce="variant-selector" className={`space-y-4 ${className}`}>
|
|
26
|
+
{view.axes.map((axis) => (
|
|
27
|
+
<fieldset key={axis.key} data-commerce="variant-axis" data-commerce-axis={axis.name}>
|
|
28
|
+
<legend className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
|
29
|
+
{axis.name}
|
|
30
|
+
{view.selection?.[axis.key] ? `: ${view.selection[axis.key]}` : ""}
|
|
31
|
+
</legend>
|
|
32
|
+
<div className="flex flex-wrap gap-2">
|
|
33
|
+
{axis.options.map((option) => {
|
|
34
|
+
const state = view.availability?.[axis.key]?.[option] ?? "available";
|
|
35
|
+
const selected = view.selection?.[axis.key] === option;
|
|
36
|
+
const disabled = state === OPTION_UNAVAILABLE;
|
|
37
|
+
const pick = () => onPick?.(axis.key, option);
|
|
38
|
+
|
|
39
|
+
if (renderOption) {
|
|
40
|
+
return (
|
|
41
|
+
<React.Fragment key={option}>
|
|
42
|
+
{renderOption({ axis, option, selected, state, disabled, pick })}
|
|
43
|
+
</React.Fragment>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
key={option}
|
|
49
|
+
type="button"
|
|
50
|
+
data-commerce="variant-option"
|
|
51
|
+
data-commerce-state={state}
|
|
52
|
+
aria-pressed={selected}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onClick={pick}
|
|
55
|
+
title={
|
|
56
|
+
state === OPTION_OUT_OF_STOCK
|
|
57
|
+
? "Out of stock"
|
|
58
|
+
: disabled
|
|
59
|
+
? "Not available with your other choices"
|
|
60
|
+
: undefined
|
|
61
|
+
}
|
|
62
|
+
className={`border px-3 py-1.5 text-sm ${
|
|
63
|
+
selected ? "border-foreground" : "border-border"
|
|
64
|
+
} ${state === OPTION_OUT_OF_STOCK ? "line-through opacity-60" : ""} disabled:opacity-40`}
|
|
65
|
+
>
|
|
66
|
+
{option}
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</div>
|
|
71
|
+
</fieldset>
|
|
72
|
+
))}
|
|
73
|
+
|
|
74
|
+
{view.missingAxes?.length > 0 && (
|
|
75
|
+
<p data-commerce="variant-hint" className="text-xs text-muted-foreground">
|
|
76
|
+
Choose {view.missingAxes.map((a) => a.name.toLowerCase()).join(" and ")} to continue.
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|