@base44/app-plugin-commerce 0.3.4 → 0.4.0

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.
@@ -0,0 +1,140 @@
1
+ import React, { createContext, useContext, useMemo } from "react";
2
+
3
+ /**
4
+ * Labels plumbing for the parts layer. The parts ship NO customer-facing
5
+ * string and no English fallback: every word resolves through this chain —
6
+ * part `labels` prop → nearest scope (`Checkout.Root`, `OrderReceived.Root`) →
7
+ * `<StorefrontProvider labels={…}>` — and a missing key renders a visible
8
+ * `⟨copy: path⟩` placeholder plus one console warning. The full key list and
9
+ * a copyable English reference map live in the commerce skill's
10
+ * install/02-storefront.md; the store rewrites every value in its own voice.
11
+ */
12
+
13
+ /** Every label path the parts resolve. The docs' copy example must cover exactly these. */
14
+ export const REQUIRED_LABEL_KEYS = [
15
+ "placeOrder.label",
16
+ "placeOrder.placing",
17
+ "blockers.empty_cart",
18
+ "blockers.billing_incomplete",
19
+ "blockers.shipping_address_incomplete",
20
+ "blockers.shipping_address_required",
21
+ "blockers.shipping_method_required",
22
+ "blockers.shipping_not_available",
23
+ "blockers.payment_method_required",
24
+ "blockers.cart_loading",
25
+ "blockers.shipping_recalculating",
26
+ "shipping.missing_address",
27
+ "shipping.none_available",
28
+ "shipping.syncing",
29
+ "payment.none_available",
30
+ "coupon.placeholder",
31
+ "coupon.apply",
32
+ "coupon.remove",
33
+ "fields.first_name",
34
+ "fields.last_name",
35
+ "fields.email",
36
+ "fields.company",
37
+ "fields.address_1",
38
+ "fields.address_2",
39
+ "fields.country",
40
+ "fields.city",
41
+ "fields.state",
42
+ "fields.postcode",
43
+ "fields.phone",
44
+ "fields.select_placeholder",
45
+ "totals.subtotal",
46
+ "totals.discount",
47
+ "totals.shipping",
48
+ "totals.tax",
49
+ "totals.total",
50
+ "bank.account_name",
51
+ "bank.account_number",
52
+ "bank.bank_name",
53
+ "bank.sort_code",
54
+ "bank.iban",
55
+ "bank.bic",
56
+ "aria.trigger",
57
+ "aria.drawer",
58
+ "aria.close",
59
+ "aria.increase",
60
+ "aria.decrease",
61
+ "aria.remove",
62
+ "aria.shipping_group",
63
+ "aria.payment_group",
64
+ ];
65
+
66
+ const LabelsContext = createContext(null);
67
+
68
+ /** Two-level merge: `override`'s groups merge over `base`'s, key by key. */
69
+ export function mergeLabels(base, override) {
70
+ if (!base) return override ?? null;
71
+ if (!override) return base;
72
+ const out = { ...base };
73
+ for (const key of Object.keys(override)) {
74
+ const b = base[key];
75
+ const o = override[key];
76
+ out[key] =
77
+ b && o && typeof b === "object" && typeof o === "object" && !Array.isArray(b) && !Array.isArray(o)
78
+ ? { ...b, ...o }
79
+ : o;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Scope provider: merges `labels` over whatever is already in scope. */
85
+ export function LabelsScope({ labels, children }) {
86
+ const parent = useContext(LabelsContext);
87
+ const merged = useMemo(() => mergeLabels(parent, labels), [parent, labels]);
88
+ return <LabelsContext.Provider value={merged}>{children}</LabelsContext.Provider>;
89
+ }
90
+
91
+ /** The labels in scope, with a part-level override merged on top. */
92
+ export function useResolvedLabels(partLabels) {
93
+ const ctx = useContext(LabelsContext);
94
+ return useMemo(() => mergeLabels(ctx, partLabels), [ctx, partLabels]);
95
+ }
96
+
97
+ const warned = new Set();
98
+ function missing(path) {
99
+ if (!warned.has(path)) {
100
+ warned.add(path);
101
+ if (typeof console !== "undefined") {
102
+ console.warn(
103
+ `[commerce] Missing label "${path}" — the page renders a placeholder until the store's copy provides it (the key list and a reference map are in the commerce skill's install/02-storefront.md).`,
104
+ );
105
+ }
106
+ }
107
+ return `⟨copy: ${path}⟩`;
108
+ }
109
+
110
+ function walk(labels, path) {
111
+ let v = labels;
112
+ for (const key of path.split(".")) {
113
+ if (v == null || typeof v !== "object") return undefined;
114
+ v = v[key];
115
+ }
116
+ return v;
117
+ }
118
+
119
+ /** A plain label string, or the visible placeholder when unprovided. */
120
+ export function label(labels, path) {
121
+ const v = walk(labels, path);
122
+ return typeof v === "string" && v !== "" ? v : missing(path);
123
+ }
124
+
125
+ /**
126
+ * An accessible-name template resolved with the item's name — the stored
127
+ * value may be a function `(name) => string` or a string containing `{name}`.
128
+ */
129
+ export function nameLabel(labels, path, name) {
130
+ const v = walk(labels, path);
131
+ if (typeof v === "function") {
132
+ try {
133
+ return String(v(name));
134
+ } catch {
135
+ return missing(path);
136
+ }
137
+ }
138
+ if (typeof v === "string" && v !== "") return v.replace("{name}", String(name ?? ""));
139
+ return missing(path);
140
+ }
@@ -0,0 +1,186 @@
1
+ import React, { createContext, useContext } from "react";
2
+ import { orderTotalsLines } from "@/commerce/utils";
3
+ import { useFormatMoney } from "../StorefrontProvider";
4
+ import { useOrderReturn } from "../useOrderReturn";
5
+ import { LabelsScope, label, useResolvedLabels } from "./labels";
6
+ import { totalsLabels } from "./shared";
7
+
8
+ /**
9
+ * Order-received parts — the mandatory receipt route as placeable sections.
10
+ * Provider-agnostic by construction: everything renders from the return
11
+ * payload (`order`, `payment_link`, `payment_instructions`) — no provider is
12
+ * imported or named, so the stub, the Stripe file, any other card-payment
13
+ * implementation and the manual methods all behave identically. Contract,
14
+ * styling and labels: the commerce skill's install/02-storefront.md.
15
+ */
16
+
17
+ const OrderReturnContext = createContext(null);
18
+
19
+ /** Resolves `useOrderReturn()` once and scopes `labels`; the gates below branch on it. */
20
+ function Root({ labels, children }) {
21
+ const ret = useOrderReturn();
22
+ return (
23
+ <LabelsScope labels={labels}>
24
+ <OrderReturnContext.Provider value={ret}>{children}</OrderReturnContext.Provider>
25
+ </LabelsScope>
26
+ );
27
+ }
28
+
29
+ function useReturnCtx(name) {
30
+ const ctx = useContext(OrderReturnContext);
31
+ if (!ctx) throw new Error(`<OrderReceived.${name}> works inside <OrderReceived.Root> only.`);
32
+ return ctx;
33
+ }
34
+
35
+ /**
36
+ * Gates over the five return states. Render ALL five — a blank frame while
37
+ * "loading" or a wordless "error" strands a real customer. Children may be a
38
+ * node or a function of `{ order, paymentLink, paymentInstructions, error,
39
+ * reload }` (render `error.message`, retry via `reload()`).
40
+ */
41
+ function gate(name, match) {
42
+ function Gate({ children }) {
43
+ const ret = useReturnCtx(name);
44
+ if (ret.status !== match) return null;
45
+ return typeof children === "function"
46
+ ? (children({
47
+ order: ret.order ?? null,
48
+ paymentLink: ret.paymentLink ?? null,
49
+ paymentInstructions: ret.paymentInstructions ?? null,
50
+ error: ret.error ?? null,
51
+ reload: ret.reload,
52
+ }) ?? null)
53
+ : (children ?? null);
54
+ }
55
+ Gate.displayName = `OrderReceived.${name}`;
56
+ return Gate;
57
+ }
58
+
59
+ const Loading = gate("Loading", "loading");
60
+ const Paid = gate("Paid", "paid");
61
+ const Unpaid = gate("Unpaid", "unpaid");
62
+ const Cancelled = gate("Cancelled", "cancelled");
63
+ const ErrorState = gate("Error", "error");
64
+
65
+ /** The order's lines, already normalized (image is `{src, alt}|null`, money pre-formatted). */
66
+ function Items({ itemRender, className, classes }) {
67
+ const ret = useReturnCtx("Items");
68
+ const lines = ret.lines ?? [];
69
+ if (!lines.length) return null;
70
+ return (
71
+ <ul data-part="items" className={className}>
72
+ {lines.map((line, i) => (
73
+ <li key={`${line.sku || line.name}-${i}`} data-part="row" className={classes?.row}>
74
+ {itemRender ? (
75
+ itemRender(line)
76
+ ) : (
77
+ <>
78
+ {line.image ? (
79
+ <img data-part="media" className={classes?.media} src={line.image.src} alt={line.image.alt} loading="lazy" />
80
+ ) : (
81
+ <div data-part="media" data-empty="" className={classes?.media} />
82
+ )}
83
+ <span data-part="name" className={classes?.name}>{line.name}</span>
84
+ {line.attributesLabel ? (
85
+ <span data-part="attributes" className={classes?.attributes}>{line.attributesLabel}</span>
86
+ ) : null}
87
+ <span data-part="quantity" className={classes?.quantity}>{line.quantity}</span>
88
+ <span data-part="line-total" className={classes?.["line-total"]}>{line.totalLabel}</span>
89
+ </>
90
+ )}
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ );
95
+ }
96
+
97
+ /** The receipt's summary rows — an order's totals are FLAT, and this part owns that trap. */
98
+ function Totals({ pick, className, classes, labels: partLabels }) {
99
+ const ret = useReturnCtx("Totals");
100
+ const L = useResolvedLabels(partLabels);
101
+ const formatMoney = useFormatMoney();
102
+ if (!ret.order) return null;
103
+ const rows = orderTotalsLines(ret.order, { formatMoney, labels: totalsLabels(L) }).filter(
104
+ (r) => !r.hidden && (!pick || pick.includes(r.key)),
105
+ );
106
+ return (
107
+ <dl data-part="totals" className={className}>
108
+ {rows.map((r) => (
109
+ <div
110
+ key={r.key}
111
+ data-part="row"
112
+ data-key={r.key}
113
+ data-emphasis={r.emphasis || undefined}
114
+ className={classes?.row}
115
+ >
116
+ <dt data-part="label" className={classes?.label}>{r.label}</dt>
117
+ <dd data-part="value" className={classes?.value}>{r.formatted}</dd>
118
+ </div>
119
+ ))}
120
+ </dl>
121
+ );
122
+ }
123
+
124
+ const BANK_FIELDS = ["account_name", "account_number", "bank_name", "sort_code", "iban", "bic"];
125
+
126
+ /**
127
+ * How a manual/offline order gets paid — the admin's description plus each
128
+ * configured account's details. These ARE the store's default payment flow:
129
+ * an unpaid manual order's receipt must place this part. Renders null when
130
+ * the order carries no instructions (e.g. a card order).
131
+ */
132
+ function PaymentInstructions({ className, classes, labels: partLabels }) {
133
+ const ret = useReturnCtx("PaymentInstructions");
134
+ const L = useResolvedLabels(partLabels);
135
+ const pi = ret.paymentInstructions;
136
+ if (!pi) return null;
137
+ const accounts = Array.isArray(pi.account_details) ? pi.account_details : [];
138
+ return (
139
+ <div data-part="payment-instructions" className={className}>
140
+ {pi.description && (
141
+ <p data-part="description" className={classes?.description}>{pi.description}</p>
142
+ )}
143
+ {accounts.map((acc, i) => (
144
+ <dl key={i} data-part="account" className={classes?.account}>
145
+ {BANK_FIELDS.filter((k) => acc?.[k]).map((k) => (
146
+ <div key={k} data-part="row" data-key={k} className={classes?.row}>
147
+ <dt data-part="label" className={classes?.label}>{label(L, `bank.${k}`)}</dt>
148
+ <dd data-part="value" className={classes?.value}>{acc[k]}</dd>
149
+ </div>
150
+ ))}
151
+ </dl>
152
+ ))}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * The navigation-wrapper pattern: renders its function child ONLY when the
159
+ * order has a live payment link (unpaid/cancelled card orders) — the element
160
+ * is entirely the store's: `{({ url }) => <a href={url}>…</a>}` or whatever
161
+ * the store navigates with. The kit never renders the link itself.
162
+ */
163
+ function PaymentLink({ children }) {
164
+ const ret = useReturnCtx("PaymentLink");
165
+ const url = ret.paymentLink?.url;
166
+ if (!url) return null;
167
+ if (typeof children !== "function") {
168
+ throw new Error(
169
+ "<OrderReceived.PaymentLink> takes a function child: ({ url }) => your own element — the kit never renders navigation.",
170
+ );
171
+ }
172
+ return <>{children({ url })}</>;
173
+ }
174
+
175
+ export const OrderReceived = {
176
+ Root,
177
+ Loading,
178
+ Paid,
179
+ Unpaid,
180
+ Cancelled,
181
+ Error: ErrorState,
182
+ Items,
183
+ Totals,
184
+ PaymentInstructions,
185
+ PaymentLink,
186
+ };
@@ -0,0 +1,127 @@
1
+ import React, { useState } from "react";
2
+ import { cartTotalsLines } from "@/commerce/utils";
3
+ import { useCart, useFormatMoney } from "../StorefrontProvider";
4
+ import { label, useResolvedLabels } from "./labels";
5
+
6
+ /**
7
+ * Parts shared by the cart and the checkout (exported as `Cart.CouponField` /
8
+ * `Checkout.CouponField` and `Cart.Totals` / `Checkout.Totals` — one
9
+ * component each, both read the ONE shared cart). Contract, styling and
10
+ * labels: the commerce skill's install/02-storefront.md.
11
+ */
12
+
13
+ /**
14
+ * The coupon input + apply + applied-codes row. A store with any coupons must
15
+ * place this once (cart or checkout) or its codes can never be redeemed. An
16
+ * invalid code renders the server's own message inline. Enter applies (no
17
+ * <form> is rendered, so it nests safely inside one).
18
+ */
19
+ export function CouponField({ className, classes, labels: partLabels }) {
20
+ const L = useResolvedLabels(partLabels);
21
+ const { cart, applyCoupon, removeCoupon } = useCart();
22
+ const [code, setCode] = useState("");
23
+ const [busy, setBusy] = useState(false);
24
+ const [error, setError] = useState(null);
25
+
26
+ const submit = async () => {
27
+ const value = code.trim();
28
+ if (!value || busy) return;
29
+ setBusy(true);
30
+ setError(null);
31
+ const res = await applyCoupon(value); // resolves {ok:false} — never throws
32
+ if (res.ok) setCode("");
33
+ else setError(res);
34
+ setBusy(false);
35
+ };
36
+
37
+ const applied = cart?.coupon_codes ?? [];
38
+ return (
39
+ <div data-part="coupon-field" data-busy={busy || undefined} className={className}>
40
+ <input
41
+ data-part="input"
42
+ className={classes?.input}
43
+ value={code}
44
+ placeholder={label(L, "coupon.placeholder")}
45
+ aria-label={label(L, "coupon.placeholder")}
46
+ disabled={busy}
47
+ onChange={(e) => setCode(e.target.value)}
48
+ onKeyDown={(e) => {
49
+ if (e.key === "Enter") {
50
+ e.preventDefault();
51
+ submit();
52
+ }
53
+ }}
54
+ />
55
+ <button
56
+ type="button"
57
+ data-part="apply"
58
+ className={classes?.apply}
59
+ onClick={submit}
60
+ disabled={busy || !code.trim()}
61
+ >
62
+ {label(L, "coupon.apply")}
63
+ </button>
64
+ {error && (
65
+ <p data-part="error" role="alert" className={classes?.error}>
66
+ {error.message}
67
+ </p>
68
+ )}
69
+ {applied.map((c) => (
70
+ <span key={c} data-part="applied" className={classes?.applied}>
71
+ <span data-part="code" className={classes?.code}>{c}</span>
72
+ <button
73
+ type="button"
74
+ data-part="remove"
75
+ className={classes?.remove}
76
+ onClick={() => removeCoupon(c).catch(() => {})}
77
+ >
78
+ {label(L, "coupon.remove")}
79
+ </button>
80
+ </span>
81
+ ))}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The cart's summary lines — every non-hidden row from `cartTotalsLines`
88
+ * (discount and tax included), `data-emphasis` on the total. `pick` narrows to
89
+ * a subset of row keys (`["subtotal"]` for a drawer footer). Row names come
90
+ * from `labels.totals`; amounts stay the engine's.
91
+ */
92
+ export function Totals({ pick, className, classes, labels: partLabels }) {
93
+ const L = useResolvedLabels(partLabels);
94
+ const { cart } = useCart();
95
+ const formatMoney = useFormatMoney();
96
+ if (!cart) return null;
97
+ const rows = cartTotalsLines(cart, { formatMoney, labels: totalsLabels(L) }).filter(
98
+ (r) => !r.hidden && (!pick || pick.includes(r.key)),
99
+ );
100
+ return (
101
+ <dl data-part="totals" className={className}>
102
+ {rows.map((r) => (
103
+ <div
104
+ key={r.key}
105
+ data-part="row"
106
+ data-key={r.key}
107
+ data-emphasis={r.emphasis || undefined}
108
+ className={classes?.row}
109
+ >
110
+ <dt data-part="label" className={classes?.label}>{r.label}</dt>
111
+ <dd data-part="value" className={classes?.value}>{r.formatted}</dd>
112
+ </div>
113
+ ))}
114
+ </dl>
115
+ );
116
+ }
117
+
118
+ /** The five static row names, resolved through the labels chain (never the utils' defaults). */
119
+ export function totalsLabels(L) {
120
+ return {
121
+ subtotal: label(L, "totals.subtotal"),
122
+ discount: label(L, "totals.discount"),
123
+ shipping: label(L, "totals.shipping"),
124
+ tax: label(L, "totals.tax"),
125
+ total: label(L, "totals.total"),
126
+ };
127
+ }