@base44/app-plugin-commerce 0.1.5 → 0.1.7
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 +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +84 -31
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +76 -41
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -191
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +4 -5
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
|
@@ -1,201 +1,34 @@
|
|
|
1
|
-
# Online payments
|
|
1
|
+
# Online (card) payments
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The store ships with a **Credit card** checkout option (`commerce.PaymentGateway` slug `card`) and everything around it already built — order creation, totals, stock holds, the two idempotent confirmation paths (customer return + provider webhook), payment links for unpaid orders, refund records, emails, and the admin's payment panel.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- `base44/shared/commerce/stripe.ts` — the Stripe adapter (the only file that speaks Stripe).
|
|
7
|
-
- `base44/functions/commerce/payments/` — `status` · `create-link` · `complete-return` · `verify`.
|
|
8
|
-
- `base44/functions/commerce/payment-webhook/` — the provider's signed callback.
|
|
9
|
-
- `commerce/storefront-checkout` `place-order` returns a `checkout_url`; `commerce/admin-refunds` refunds through the provider; the admin order page has a **Payment** panel.
|
|
5
|
+
What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal, Adyen, a local PSP…) means implementing **exactly two files** — nothing else changes, no entity is touched, no caller or UI needs editing:
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
| # | File | Implement |
|
|
8
|
+
|---|------|-----------|
|
|
9
|
+
| 1 | `base44/shared/commerce/card-payment.ts` | `createCardPayment` (make a hosted payment page for `order.total`, return `{url, reference}`), `checkCardPaymentPaid` (ask the provider whether `reference` was paid), and optionally `refundCardPayment` |
|
|
10
|
+
| 2 | `base44/functions/commerce/payment-webhook/entry.ts` | `parseWebhook` (verify the provider's signature over the raw body, return `{order_id, order_key, paid, reference?}`) |
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Until file 1 is implemented, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods); the admin can also just switch the card option off in Settings → Payments to hide it. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions, and the operator moves it on once the money arrives — those need no code at all, and the admin can add more of them in Settings → Payments.
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
## How the premade flow works
|
|
16
15
|
|
|
17
|
-
**
|
|
16
|
+
1. **Checkout** (`commerce/storefront-checkout` `place-order` with the `card` gateway): the order is created `pending`, `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` is called, the returned `reference` is stored on the order (`_payment_reference` meta), and the customer is redirected to `url`. The return URLs already carry `order_id`, `order_key` and `payment=success|cancel`.
|
|
17
|
+
2. **Confirmation — two idempotent paths**, whichever runs second is a no-op:
|
|
18
|
+
- **Customer return**: the `/order-received` page calls `commerce/payments` `complete-return`, which runs `checkCardPaymentPaid(sr, order, reference)` and, when true, moves the order to `processing` (stock/email/webhook side-effects fire from the transition).
|
|
19
|
+
- **Webhook**: register `commerce/payment-webhook`'s URL with the provider. Your `parseWebhook` verifies the signature and names the order; `paid: true` from a **verified** event is trusted directly, `paid: false` makes the premade code re-check via `checkCardPaymentPaid`. The event's `order_key` must match the order — attach `order.id` and `order.order_key` to the payment's metadata in `createCardPayment` so the event can carry them back.
|
|
20
|
+
3. **Payment links**: `commerce/payments` `create-link` mints a fresh page for any unpaid order via the same `createCardPayment` — used by the admin's payment-link button and the order-received page's "Pay now".
|
|
21
|
+
4. **Refunds**: `commerce/admin-refunds` with `refund_payment: true` calls `refundCardPayment` **before** writing the local record (a failed provider refund writes nothing). While unimplemented it answers `501 card_refund_not_implemented` — record the refund without `refund_payment` and return the money from the provider's own dashboard.
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
## Implementation rules
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
- **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
|
|
26
|
+
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. In `parseWebhook`, set `paid: true` only after verifying the request signature over the raw body bytes.
|
|
27
|
+
- **Amounts**: `order.total` is in display units (e.g. `12.34`) with `order.currency`; convert to the provider's minor units yourself if it needs them.
|
|
28
|
+
- The shared helpers in `base44/shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
## Storefront requirements (unchanged by any of this)
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- **Hosted app (no CLI)** — you cannot run a deploy command, so cause one by **changing a file the functions bundle**: edit anything under `base44/shared/commerce/` and every `commerce/*` function redeploys, because `shared/` is bundled into all of them. A one-line comment edit is enough — `shared/commerce/payments.ts` carries a **deploy marker** comment at the top for exactly this: bump its number, save, and the whole set redeploys. (Editing a single function's `entry.ts` redeploys only that function, which is not enough: `payments`, `payment-webhook`, `storefront-checkout`, `storefront-catalog`, `admin-tools` and `admin-refunds` all read the credential.)
|
|
29
|
-
- **CLI** — `npx base44 functions deploy`.
|
|
30
|
-
|
|
31
|
-
3. **Then confirm it landed** — `commerce/admin-tools` → `payment-connector-status` returns `{ connected, provider, gateway_slug }`; the admin's Payments screen shows the same state. Still *not connected*? In order: was the integration actually completed in the dashboard, did the redeploy above really happen, and has the ~60s status cache expired (`onlinePaymentStatus` caches per isolate).
|
|
32
|
-
4. **Nothing else.** The `Credit card` gateway is enabled by default and starts appearing at checkout the moment a provider is connected. No code, no keys, no placeholder to replace. (`commerce/seed-store` is idempotent and never rewrites an existing gateway, so a store seeded *before* this default landed keeps its old `enabled: false` — switch it on in Payments settings.)
|
|
33
|
-
5. **Optional but recommended: register the webhook** (§4) so payments confirm even when the buyer closes the tab.
|
|
34
|
-
6. **Test with the provider's test mode** — place an order, pay, and check the order reaches `processing` with `date_paid` and a payment reference; then try a refund. This is the other reason to do payments *after* the catalog: with products, a shipping method and an address in place, you can prove the whole path works instead of just wiring it.
|
|
35
|
-
|
|
36
|
-
**To stop taking card payments, switch the gateway off** — don't rely on disconnecting the provider in the platform dashboard. Verified on Base44: after disconnecting Stripe there, the injected `STRIPE_SECRET_KEY` was still present *and still accepted by Stripe* (across a redeploy), so the store could genuinely still charge and correctly reported card payment as available. The store-level switch in **Payments settings** is the control that always works, because it's the store's own data.
|
|
37
|
-
|
|
38
|
-
**The user can decline, and that's a legitimate answer.** Some businesses genuinely want invoice, bank transfer or cash on delivery only. If they do:
|
|
39
|
-
|
|
40
|
-
- turn the `Credit card` gateway **off** in Payments settings — that is the whole opt-out;
|
|
41
|
-
- the storefront never offers it, and the admin order page's payment actions sit disabled with *"No payment provider connected"*;
|
|
42
|
-
- say plainly in the checkout UI how payment works (`payment_instructions` from the `place-order` response), and don't describe the store as taking cards anywhere in the copy.
|
|
43
|
-
|
|
44
|
-
Don't leave the in-between state unexplained: the gateway enabled with **no provider connected** is safe (customers never see it — the storefront filters it out live), but the user should know it's waiting on them.
|
|
45
|
-
|
|
46
|
-
## 2. How it works
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
place-order (gateway = card)
|
|
50
|
-
└─ order created `pending` → payment: { status: "requires_payment", checkout_url }
|
|
51
|
-
└─ storefront redirects the customer to checkout_url (provider-hosted page)
|
|
52
|
-
├─ customer returns → commerce/payments `verify` ─┐ both idempotent,
|
|
53
|
-
└─ provider calls → commerce/payment-webhook ─┘ first one wins
|
|
54
|
-
└─ order → `processing`, date_paid set, reference stored, emails + webhooks fire
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Card data never touches this app — the customer pays on the provider's page.
|
|
58
|
-
|
|
59
|
-
**Return URLs — and the route your page actually lives at.** Providers require *absolute* URLs. Resolution, most specific first:
|
|
60
|
-
|
|
61
|
-
1. `success_url` / `cancel_url` — a full page URL per outcome;
|
|
62
|
-
2. `return_url` — **your return page**, used for both outcomes (`payment=success|cancel` distinguishes them). A bare origin (`https://shop`, no path) is instead treated as a base, since there's no page in it;
|
|
63
|
-
3. the origin the request came from (the storefront that called this) + the store's **Payment return path** (`settings.general.order_received_path`).
|
|
64
|
-
|
|
65
|
-
Every result carries `order_id`, `order_key` and `payment`, **including URLs you supplied** — a `success_url` without them can't verify anything. Existing query parameters survive. With nothing to build from, the call fails `400 return_url_required` rather than letting the provider answer "Not a valid URL".
|
|
66
|
-
|
|
67
|
-
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they resolve to the app's own origin plus the store's configured path.
|
|
68
|
-
|
|
69
|
-
### MANDATORY: the payment return page (`/order-received`)
|
|
70
|
-
|
|
71
|
-
**Payment links do not work until you build this page.** Every link comes back to it — checkout, the admin's *Create payment link*, any "pay now" in an email — so without the route a customer pays and lands on a **404**, at the worst possible moment. And because confirming the payment is what moves the order to `processing`, a missing page also means **paid orders sit unpaid forever**.
|
|
72
|
-
|
|
73
|
-
The template ships **no storefront UI**, so the page is yours to build. **If you don't call it `/order-received`, set your route in Settings → General → *Payment return path*** (`settings.general.order_received_path`) — that's what payment links are built from, and a mismatch is a 404 for a paying customer. All the logic is one backend call:
|
|
74
|
-
|
|
75
|
-
```js
|
|
76
|
-
// GET /order-received?order_id=…&order_key=…&payment=success|cancel
|
|
77
|
-
const params = new URLSearchParams(window.location.search);
|
|
78
|
-
const res = await base44.functions.invoke("commerce/payments", {
|
|
79
|
-
action: "complete-return",
|
|
80
|
-
order_id: params.get("order_id"),
|
|
81
|
-
order_key: params.get("order_key"),
|
|
82
|
-
payment: params.get("payment"), // only a hint — the server decides
|
|
83
|
-
return_url: window.location.origin, // so "pay again" can come back here
|
|
84
|
-
});
|
|
85
|
-
const { state, order, payment_link } = res.data.data;
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
`complete-return` confirms with the provider, progresses the order when the money landed, and answers with everything the page needs:
|
|
89
|
-
|
|
90
|
-
| Field | Meaning |
|
|
91
|
-
|---|---|
|
|
92
|
-
| `state` | **`paid`** → thank the customer, show the order · **`cancelled`** → they backed out, order saved and unpaid · **`unpaid`** → no payment recorded yet (closed the tab, slow bank, gave up) |
|
|
93
|
-
| `paid`, `already_confirmed`, `status` | the confirmed payment state and the order's new status |
|
|
94
|
-
| `order` | customer-safe order (order number, totals, status) to render |
|
|
95
|
-
| `payment_link` | `{ url, session_id }` while unpaid — wire it to a **Pay now** button; `null` when the store can't take card payment |
|
|
96
|
-
|
|
97
|
-
Rules for whatever you build:
|
|
98
|
-
|
|
99
|
-
- **Call `complete-return` on every visit.** It is idempotent, so refreshes and double-taps are safe, and it is what marks the order paid.
|
|
100
|
-
- **Never treat the URL as proof.** `?payment=success` is editable by anyone; `state` comes from the provider, and a hand-edited URL can never produce `paid`.
|
|
101
|
-
- **Handle all three states**, not just the happy one — `unpaid` is common (bank delays, closed tabs) and needs a *Check again* plus *Pay now*, not an error.
|
|
102
|
-
- **Don't gate it behind login.** Guests pay too; `order_key` in the link is the credential.
|
|
103
|
-
|
|
104
|
-
**Storefront wiring** (the only integration work):
|
|
105
|
-
|
|
106
|
-
```js
|
|
107
|
-
const res = await inv("commerce/storefront-checkout", {
|
|
108
|
-
action: "place-order", cart_token, payment_method: "stripe", billing, shipping,
|
|
109
|
-
success_url: `${origin}/order-received?order_id=…&order_key=…`, // optional; a sensible default is used
|
|
110
|
-
cancel_url: `${origin}/checkout`,
|
|
111
|
-
});
|
|
112
|
-
if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
|
|
113
|
-
|
|
114
|
-
// …and on the success page, confirm before showing "paid":
|
|
115
|
-
const { paid, status } = await inv("commerce/payments", { action: "verify", order_id, order_key });
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Never mark an order paid from a URL parameter — `verify` asks the provider and is the only honest answer. It is safe to call repeatedly.
|
|
119
|
-
|
|
120
|
-
**Resuming a payment**: `commerce/payments` `create-link` returns a fresh hosted-page URL for any unpaid order (`{ order_id, order_key }` for a customer, or admin auth). That's what powers "pay now" links in the admin and in order-received emails.
|
|
121
|
-
|
|
122
|
-
**Re-issuing after an edit is safe.** Each session is created with an idempotency key derived from the request itself, so an unchanged request (a double-click, a retry) reuses its session while an edited order gets a new one — keying on the order's total alone would break here, since editing an order without moving the total changes the request but not the key. And the previous session is **expired** before the new one is minted, so an old link can't be paid at the total the order used to have.
|
|
123
|
-
|
|
124
|
-
## 3. Payment links from the admin
|
|
125
|
-
|
|
126
|
-
The order page's **Payment** panel shows the method, total, paid state and payment reference, and for **any unpaid order** — with a provider connected — offers:
|
|
127
|
-
|
|
128
|
-
- **Create payment link** — the provider-hosted page for exactly that order's total; copy it to the customer.
|
|
129
|
-
- **Check payment** — re-asks the provider and moves the order on if the money arrived.
|
|
130
|
-
|
|
131
|
-
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was Offline payment)…`). That switch matters beyond tidiness: refunds key off `payment_method`, so an order paid by card must say so or it can't be refunded through the provider. The panel warns before it happens.
|
|
132
|
-
|
|
133
|
-
The one refusal is a store that has **turned online payment off** — `400 online_payments_disabled` — because that's the operator declining it, not a missing setup.
|
|
134
|
-
|
|
135
|
-
Both actions are disabled with *"No payment provider connected"* when there's no provider — the capability stays visible so the operator can see what connecting unlocks.
|
|
136
|
-
|
|
137
|
-
## 4. The webhook (recommended)
|
|
138
|
-
|
|
139
|
-
`commerce/payment-webhook` is the second confirmation path: it catches buyers who pay and close the tab before returning.
|
|
140
|
-
|
|
141
|
-
1. Register the function's URL with the provider (Stripe: *Developers → Webhooks*, events `checkout.session.completed` and `payment_intent.succeeded`).
|
|
142
|
-
2. Put the signing secret in the **`PAYMENT_WEBHOOK_SECRET`** environment variable (`STRIPE_WEBHOOK_SECRET` also works).
|
|
143
|
-
3. **On the hosted Base44 platform, also configure the app's Stripe webhook secret in the app settings.** Verified behavior: a request carrying a `stripe-signature` header is gated by the platform *before* any function runs — without that app-level secret every Stripe webhook is answered `400 "No Stripe webhook secret configured for this app"` and never reaches `commerce/payment-webhook`. Use the same secret in both places.
|
|
144
|
-
|
|
145
|
-
**The payload is never believed on its own**, which is what makes this safe with or without a secret:
|
|
146
|
-
|
|
147
|
-
- **With a signing secret**, the signature is verified here (provider scheme, timestamp tolerance so a captured request can't be replayed) and the event's "paid" is then trusted — one fewer round trip.
|
|
148
|
-
- **Without one** — the case on Base44, where a function's environment is fixed (`BASE44_APP_ID`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`) and apps can't add their own — the body counts only as *a nudge naming an order*. Whether money arrived is then asked of the provider over our own authenticated API call. A forged webhook achieves nothing beyond making the store re-check an order; it can never mark one paid.
|
|
149
|
-
|
|
150
|
-
Either way the event must carry the `order_key` matching the order it names, and any session id in it is fetched and checked to have been opened for *that* order (the provider's own copy of `order_id`/`order_key`, put there at session creation). So a genuine payment for one order can never be replayed against another — a €5 order settled for real does not confirm a €500 one. A mismatch answers `409 session_order_mismatch` (and is ignored, with a 200, on the webhook so the provider stops retrying).
|
|
151
|
-
|
|
152
|
-
So the webhook is worth registering either way. It remains a **resilience upgrade, not a prerequisite**: the return page confirms payments on its own.
|
|
153
|
-
|
|
154
|
-
## 5. Refunds
|
|
155
|
-
|
|
156
|
-
`commerce/admin-refunds` `create` with `refund_payment: true` sends the money back through the provider, then records the refund — **in that order**, so a failed provider refund never leaves a phantom refund behind. The response carries `gateway_refund: { provider, id, status, amount }` and the refund record's `refunded_payment` is true only when the provider really refunded. An order that wasn't paid online rejects `refund_payment` with `400 no_online_payment`: that money moved outside the store and has to go back the same way.
|
|
157
|
-
|
|
158
|
-
The admin's refund panel offers *"Send the money back through the payment provider"* only when the order was actually paid online and a provider is connected.
|
|
159
|
-
|
|
160
|
-
## 6. Using a different provider
|
|
161
|
-
|
|
162
|
-
The payment utility exists for this. **Write an adapter and change one line:**
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
// base44/shared/commerce/payments.ts
|
|
166
|
-
const paypalAdapter: PaymentAdapter = { id: "paypal", label: "PayPal", gatewaySlug: "paypal", /* … */ };
|
|
167
|
-
export const ACTIVE_PROVIDER: PaymentAdapter = paypalAdapter; // ← was stripeAdapter
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
`PaymentAdapter` is the whole contract: `connection`, `createSession`, `retrieveSession`, `refund`, `verifyWebhook`, `parseWebhookEvent`. Implement those against the provider's API (model `stripe.ts`, which is the reference implementation — including how it finds its credential: an injected secret first, a host connector second) and **nothing else changes** — checkout, the payment/webhook functions, refunds, the admin panel and the storefront gateway filter all go through the utility.
|
|
171
|
-
|
|
172
|
-
Then check the remaining touchpoints, all of them data or copy rather than logic:
|
|
173
|
-
|
|
174
|
-
- **The gateway record** (`commerce.PaymentGateway`, slug `stripe` by default): either keep the slug and just change `title`/`method_title`, or create a record whose slug matches your adapter's `gatewaySlug` and disable the old one. `ACTIVE_PROVIDER.gatewaySlug` is what makes a gateway "the online one".
|
|
175
|
-
- **`grep -rn "stripe" base44/ src/`** — after swapping the binding, every remaining hit should be the adapter, the seeded gateway record, or the `stripe-signature` header fallback in the webhook. If it's anywhere else, that's a leak worth fixing.
|
|
176
|
-
- **The webhook secret** env var stays `PAYMENT_WEBHOOK_SECRET`, and your `verifyWebhook` decides how to check it.
|
|
177
|
-
|
|
178
|
-
Keeping several providers live at once (card *and* PayPal, say) means going beyond a single `ACTIVE_PROVIDER`: give each its own gateway slug and resolve the adapter per order's `payment_method`. The utility is the place to add that map — callers won't need to change.
|
|
179
|
-
|
|
180
|
-
## 7. The storefront must render payment options from settings — never a hardcoded list
|
|
181
|
-
|
|
182
|
-
The available payment methods are **data**, owned by the admin (Payments settings → `commerce.PaymentGateway`). A storefront that hardcodes "Card / Bank transfer / COD" will silently disagree with the store the moment an admin toggles a gateway, reorders them, or edits a title.
|
|
183
|
-
|
|
184
|
-
Rules for any checkout UI you build:
|
|
185
|
-
|
|
186
|
-
- **Fetch, don't assume.** `commerce/storefront-catalog` → `get-store-info` returns `payment_gateways`, filtered to `enabled: true`, sorted by the admin's `order`, and with any online gateway **dropped while no provider is connected** — so what you render is always payable. Each entry is `{ slug, title, description, online }`.
|
|
187
|
-
- **Use the admin's copy.** Show `title`/`description` as the customer-facing labels instead of your own strings, and key your logic off `slug` (`online: true` marks the one that redirects to a hosted page).
|
|
188
|
-
- **Render the zero-state honestly.** An empty `payment_gateways` means checkout cannot complete; say so rather than showing a dead button.
|
|
189
|
-
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate a gateway's own rules. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
|
|
190
|
-
- **Re-fetch rather than cache hard.** An admin can enable, disable or reorder gateways at any time; a long-lived cached list is how a storefront drifts out of sync with the store.
|
|
191
|
-
- **Show `payment_instructions` from the response** for the `offline` flow rather than hardcoding bank details in the UI.
|
|
192
|
-
|
|
193
|
-
## 8. Never hardcode payment availability
|
|
194
|
-
|
|
195
|
-
Whether the store can take a card is a **live fact about the connector**, not a constant. Everything derives it:
|
|
196
|
-
|
|
197
|
-
- backend — `onlinePaymentStatus(sr)` from the payment utility. It doesn't just look for a key, it **verifies** it with the provider (cheapest authenticated call, answer cached ~60s): a credential that has been disconnected, rotated or revoked lingers in a function's environment until the next deploy, so presence alone would advertise card payment the store can no longer take. The same deploy-time injection is why a *newly* connected provider stays invisible until the functions are redeployed (§2, step 2). Expect up to a minute for a change to show. A *rejected* credential means not connected; a network blip does **not** flip a working store to "no payments" while a good answer is still cached;
|
|
198
|
-
- admin — the `usePaymentProvider()` hook (`commerce/admin-tools` → `payment-connector-status`);
|
|
199
|
-
- storefront — the filtered `payment_gateways` list.
|
|
200
|
-
|
|
201
|
-
So do **not** ship a permanent "payments not set up" banner: it disappears by itself the moment a provider is connected. And never surface a provider name in a failure state — *"No payment provider connected"* is what an operator needs to read.
|
|
32
|
+
- Redirect to `payment.checkout_url` when `place-order` returns `payment.status === "requires_payment"`.
|
|
33
|
+
- **Build the `/order-received` page** (or set your route in Settings → General → *Payment return path*): call `commerce/payments` `complete-return` with the return query params and render its `state` (`paid | cancelled | unpaid`, with `payment_link` for "Pay now" while unpaid). Without this page a paying customer lands on a 404 and the order is never marked paid.
|
|
34
|
+
- Handle `503 no_card_payment_provider` from `place-order` by telling the customer card payment is unavailable and offering the other methods.
|
|
@@ -4,8 +4,8 @@ What to show for a product, and **which view can show it**. Both storefront surf
|
|
|
4
4
|
|
|
5
5
|
| View | Call | Returns |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| Listing / grid / search results /
|
|
8
|
-
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories,
|
|
7
|
+
| Listing / grid / search results / ribbon & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
|
|
8
|
+
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
|
|
9
9
|
|
|
10
10
|
Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). Variant selection mechanics (axes, resolving a selection to a variation, unavailable combinations): [`storefront-product-page.md`](./storefront-product-page.md).
|
|
11
11
|
|
|
@@ -15,7 +15,7 @@ You choose what belongs in each view — but you can only render what the call r
|
|
|
15
15
|
|
|
16
16
|
## 1. Field availability
|
|
17
17
|
|
|
18
|
-
A listing **row** is the product record itself (minus paywalled fields), plus resolved `
|
|
18
|
+
A listing **row** is the product record itself (minus paywalled fields), plus resolved `ribbons`. `get-product` adds everything that needs a second read.
|
|
19
19
|
|
|
20
20
|
| Data | `list-products` row | `get-product` | Notes |
|
|
21
21
|
|---|---|---|---|
|
|
@@ -25,25 +25,25 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
|
|
|
25
25
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
26
26
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
27
27
|
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
|
|
28
|
-
| **`
|
|
29
|
-
| `
|
|
28
|
+
| **`ribbons`** (resolved `{id, name}`) | ✅ | ✅ | `get-product` returns the full ribbon records; rows carry the short form |
|
|
29
|
+
| `ribbon_ids`, `category_ids` | ✅ | ✅ | Raw ids |
|
|
30
30
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | See §5 to add |
|
|
31
31
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | The reason a product with variants can't be fully priced from a row |
|
|
32
32
|
| **`reviews`** (paged items + `average_rating`/`rating_count`) | ❌ | ✅ | Row still has the aggregate numbers |
|
|
33
33
|
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
34
34
|
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** exposed publicly — paywalled. Gated by `commerce/storefront-account` `get-download` |
|
|
35
35
|
|
|
36
|
-
## 2.
|
|
36
|
+
## 2. Ribbons — render them in **both** views
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
|
|
39
39
|
|
|
40
|
-
- **Listing rows carry resolved `
|
|
41
|
-
- **On the product page**, render `product.
|
|
42
|
-
- **Every
|
|
43
|
-
- **Offer
|
|
44
|
-
- **
|
|
45
|
-
- Don't put
|
|
46
|
-
- Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not
|
|
40
|
+
- **Listing rows carry resolved `ribbons`** (`{id, name}`), so a card can render them with no extra call. One or two per card is useful (a corner label like "Best Seller"); more is noise. Hide the block when the array is empty — never a dangling "Ribbons:" label.
|
|
41
|
+
- **On the product page**, render `product.ribbons` (the top-level `ribbons` array) as labels near the metadata, visually lighter than the category breadcrumb.
|
|
42
|
+
- **Every ribbon can link to a filtered listing** — `list-products` with `ribbon_id` — never a dead label. A ribbon has no slug, so key the URL on its id (`/shop?ribbon=<ribbon_id>`) so the page is shareable and survives reload. Use `list-ribbons` for a name to label it with.
|
|
43
|
+
- **Offer ribbons as a listing filter** from `list-ribbons`, which hides ribbons no published product carries and gives a `count` for labels like "Gift (12)". `ribbon_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
44
|
+
- **Ribbon landing page:** `list-products` filtered by `ribbon_id`, with the ribbon's `name` as the heading.
|
|
45
|
+
- Don't put ribbons in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a ribbon.
|
|
46
|
+
- Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not ribbons and not attributes. Render them as a spec table on the product page.
|
|
47
47
|
|
|
48
48
|
## 3. Product shapes in each view
|
|
49
49
|
|
|
@@ -66,9 +66,9 @@ A listing row has no `variations`, so a card still cannot compute the full range
|
|
|
66
66
|
|
|
67
67
|
## 4. Sensible defaults per view
|
|
68
68
|
|
|
69
|
-
**Card:** image, name, price (or range), sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two
|
|
69
|
+
**Card:** image, name, price (or range), sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page.
|
|
70
70
|
|
|
71
|
-
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb,
|
|
71
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells.
|
|
72
72
|
|
|
73
73
|
## 5. Adding a `get-product`-only field to the list view
|
|
74
74
|
|
|
@@ -77,10 +77,10 @@ If a customer asks for something the row doesn't carry — resolved categories a
|
|
|
77
77
|
`list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
|
|
78
78
|
|
|
79
79
|
```ts
|
|
80
|
-
const pageItems = await
|
|
80
|
+
const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
`
|
|
83
|
+
`withRibbons` reads the taxonomy **once per request** and maps ids on the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to the fields the UI renders; a listing payload is served on every browse.
|
|
84
84
|
|
|
85
85
|
What not to do:
|
|
86
86
|
|
|
@@ -6,15 +6,21 @@ What ships, with nothing to write on the backend:
|
|
|
6
6
|
|
|
7
7
|
- `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
|
|
8
8
|
- `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
|
|
9
|
-
- `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a
|
|
10
|
-
- `commerce/storefront-account` `my-reviews` lists a customer's own;
|
|
9
|
+
- `commerce/storefront-catalog` `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. For a signed-in caller the session email always wins (the payload cannot impersonate); a guest supplies `email`. `verified` is derived from that email's order history;
|
|
10
|
+
- `commerce/storefront-account` `my-reviews` lists a signed-in customer's own;
|
|
11
11
|
- moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**The one server-side switch is auto-approval** (`products.auto_approve_reviews`, the toggle on the admin's Reviews screen): off (the default) holds every new review as `hold` for moderation; on publishes immediately. Tell the submitter their review is awaiting approval rather than showing it as live.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
- a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
|
|
17
|
-
- `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
|
|
18
|
-
- the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
|
|
15
|
+
## Policies are yours to enforce in the UI
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
The server deliberately ships the **most open default — anyone can review by email**. Anything stricter is storefront policy, implemented where you build the form:
|
|
18
|
+
|
|
19
|
+
- **Login-gated reviews** — render the form only for a signed-in session (`base44.auth.me()`), and omit `email` from the payload: the session email is used automatically.
|
|
20
|
+
- **Verified buyers only** — before showing the form to a signed-in customer, check their history: `commerce/storefront-account` `my-orders` and look for a `processing`/`completed` order containing the product. (For guests there is nothing to check against — pair this policy with login-gating.) The `verified` flag on every stored review tells moderators and readers which reviews came from buyers either way — an honest middle ground is to accept all reviews and render a "Verified purchase" badge from it.
|
|
21
|
+
- **Required rating** — `rating` is optional server-side (0–5 when present); make the stars mandatory in the form before submitting if the store wants no unrated reviews.
|
|
22
|
+
- **Disabling reviews store-wide** — simply build no review UI. There is no server switch to keep in sync.
|
|
23
|
+
|
|
24
|
+
If a policy must hold even against handcrafted API calls (not just your UI), enforce it in a backend function of your own that wraps `submit-review` — the same pattern as any custom storefront action.
|
|
25
|
+
|
|
26
|
+
Shapes and error codes (`400 email_required|review_incomplete|invalid_rating`): [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
|
|
@@ -80,4 +80,4 @@ Handle `400 variation_required` (empty `variation_id` on a product with attribut
|
|
|
80
80
|
- [ ] Initial state from `defaultSelection`; selection mirrored into the URL.
|
|
81
81
|
- [ ] **Add to cart** gated on `view.purchasable`, sending `view.addToCart`.
|
|
82
82
|
- [ ] A product with empty `axes[].options` shows as unavailable — no empty selector groups, no "Select a undefined".
|
|
83
|
-
- [ ]
|
|
83
|
+
- [ ] Ribbons, ratings, upsells and the rest per [`product-render.md`](./product-render.md).
|
|
@@ -4,8 +4,8 @@ React admin UI for the Base44 commerce template. Copy this
|
|
|
4
4
|
folder into a Base44 app built on the default template (Vite + React +
|
|
5
5
|
Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
6
6
|
|
|
7
|
-
> Install docs: [
|
|
8
|
-
> API references: [
|
|
7
|
+
> Install docs: [`.agents/skills/commerce/installation-guidelines.md`](../../../.agents/skills/commerce/installation-guidelines.md) · mounting & role setup: [`.agents/skills/commerce/post-installation.md`](../../../.agents/skills/commerce/post-installation.md) · architecture & operations: the commerce skill, [`.agents/skills/commerce/SKILL.md`](../../../.agents/skills/commerce/SKILL.md)
|
|
8
|
+
> API references: [`.agents/skills/commerce/docs/api-admin.md`](../../../.agents/skills/commerce/docs/api-admin.md), [`.agents/skills/commerce/docs/api-storefront.md`](../../../.agents/skills/commerce/docs/api-storefront.md)
|
|
9
9
|
|
|
10
10
|
## Mounting
|
|
11
11
|
|
|
@@ -39,7 +39,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
39
39
|
sample data when the store has no products yet. If `commerce/seed-store` was
|
|
40
40
|
already run during installation — including when an agent generated a real
|
|
41
41
|
catalog — the store counts as ready and this screen never shows; see
|
|
42
|
-
|
|
42
|
+
`.agents/skills/commerce/post-installation.md` §2.
|
|
43
43
|
|
|
44
44
|
## External touchpoints
|
|
45
45
|
|
|
@@ -77,8 +77,7 @@ routes.jsx Route table + <AdminRoutes/>
|
|
|
77
77
|
layout/ AdminLayout, Sidebar, Topbar, AuthGuard (admin-role gate), AccessDenied
|
|
78
78
|
bot/ StoreAdminBot (chat panel over the commerce/StoreAdmin agent), Markdown (GFM renderer)
|
|
79
79
|
context/ SettingsContext (store settings + first-run seeding), BasePathContext
|
|
80
|
-
hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce
|
|
81
|
-
usePaymentProvider (is an online payment provider connected?)
|
|
80
|
+
hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce
|
|
82
81
|
lib/ api (function calls), constants, format, geo-data, order/product utils
|
|
83
82
|
components/ DataTable, SearchSelect, MoneyInput, DateRangePicker, AddressForm, …
|
|
84
83
|
pages/ All admin pages (orders, products, coupons, customers, reports, settings, webhooks)
|
|
@@ -16,7 +16,7 @@ import { useAdminHref } from "../context/BasePathContext";
|
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
18
|
* `admin:` links are the agent's way to send the operator to a page it cannot
|
|
19
|
-
* act on itself (`[Settings → Tax](admin:settings/tax)`). Resolved through
|
|
19
|
+
* act on itself (`[Settings → Shipping & Tax](admin:settings/shipping-tax)`). Resolved through
|
|
20
20
|
* useAdminHref so they follow the actual mount point, and routed in-app rather
|
|
21
21
|
* than opening a tab. Everything else stays an external link.
|
|
22
22
|
*/
|
|
@@ -3,7 +3,9 @@ import { useSettings } from "../context/SettingsContext";
|
|
|
3
3
|
import { CURRENCIES } from "../lib/constants";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Currency formatter driven by
|
|
6
|
+
* Currency formatter driven by `general.currency`. Formatting itself is
|
|
7
|
+
* localization's job — Intl.NumberFormat renders the symbol, separators and
|
|
8
|
+
* decimals for the browser locale; there are no format settings.
|
|
7
9
|
* Returns { format(n), symbol, code, decimals }.
|
|
8
10
|
*/
|
|
9
11
|
export default function useMoney() {
|
|
@@ -13,13 +15,17 @@ export default function useMoney() {
|
|
|
13
15
|
const get = settings?.get || (() => undefined);
|
|
14
16
|
const code = get("general", "currency", "USD");
|
|
15
17
|
const currency = CURRENCIES.find((cc) => cc.code === code);
|
|
18
|
+
let formatter = null;
|
|
19
|
+
try {
|
|
20
|
+
formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: code });
|
|
21
|
+
} catch {
|
|
22
|
+
formatter = null; // unknown code — fall back below
|
|
23
|
+
}
|
|
16
24
|
return {
|
|
17
25
|
code,
|
|
18
26
|
symbol: currency?.symbol || code,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
decimalSep: get("general", "decimal_sep", "."),
|
|
22
|
-
decimals: get("general", "num_decimals", currency?.decimals ?? 2),
|
|
27
|
+
decimals: currency?.decimals ?? 2,
|
|
28
|
+
formatter,
|
|
23
29
|
};
|
|
24
30
|
}, [settings]);
|
|
25
31
|
|
|
@@ -27,23 +33,8 @@ export default function useMoney() {
|
|
|
27
33
|
(n) => {
|
|
28
34
|
const num = Number(n);
|
|
29
35
|
const val = isNaN(num) ? 0 : num;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const [intPart, decPart] = fixed.split(".");
|
|
33
|
-
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, cfg.thousandSep);
|
|
34
|
-
const amount = decPart ? grouped + cfg.decimalSep + decPart : grouped;
|
|
35
|
-
const sign = negative ? "-" : "";
|
|
36
|
-
switch (cfg.position) {
|
|
37
|
-
case "right":
|
|
38
|
-
return `${sign}${amount}${cfg.symbol}`;
|
|
39
|
-
case "left_space":
|
|
40
|
-
return `${sign}${cfg.symbol} ${amount}`;
|
|
41
|
-
case "right_space":
|
|
42
|
-
return `${sign}${amount} ${cfg.symbol}`;
|
|
43
|
-
case "left":
|
|
44
|
-
default:
|
|
45
|
-
return `${sign}${cfg.symbol}${amount}`;
|
|
46
|
-
}
|
|
36
|
+
if (cfg.formatter) return cfg.formatter.format(val);
|
|
37
|
+
return `${cfg.symbol} ${val.toFixed(cfg.decimals)}`;
|
|
47
38
|
},
|
|
48
39
|
[cfg]
|
|
49
40
|
);
|
|
@@ -24,7 +24,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Blocks the admin UI unless the caller is an authenticated user with
|
|
27
|
-
* role === "admin". Do NOT weaken this check — see skills/commerce/post-installation.md.
|
|
27
|
+
* role === "admin". Do NOT weaken this check — see .agents/skills/commerce/post-installation.md.
|
|
28
28
|
* (Server-side RLS + requireAdmin() in functions enforce this independently.)
|
|
29
29
|
*
|
|
30
30
|
* Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
|
|
@@ -17,13 +17,6 @@ export const PRODUCT_STATUSES = [
|
|
|
17
17
|
{ value: "publish", label: "Published", color: "bg-green-100 text-green-800 border-green-200" },
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
-
export const CATALOG_VISIBILITIES = [
|
|
21
|
-
{ value: "visible", label: "Shop and search results" },
|
|
22
|
-
{ value: "catalog", label: "Shop only" },
|
|
23
|
-
{ value: "search", label: "Search results only" },
|
|
24
|
-
{ value: "hidden", label: "Hidden" },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
20
|
export const STOCK_STATUSES = [
|
|
28
21
|
{ value: "instock", label: "In stock", color: "bg-green-100 text-green-800 border-green-200" },
|
|
29
22
|
{ value: "outofstock", label: "Out of stock", color: "bg-red-100 text-red-800 border-red-200" },
|
|
@@ -38,7 +31,6 @@ export const BACKORDER_OPTIONS = [
|
|
|
38
31
|
|
|
39
32
|
export const TAX_STATUSES = [
|
|
40
33
|
{ value: "taxable", label: "Taxable" },
|
|
41
|
-
{ value: "shipping", label: "Shipping only" },
|
|
42
34
|
{ value: "none", label: "None" },
|
|
43
35
|
];
|
|
44
36
|
|
|
@@ -68,20 +60,6 @@ export const WEBHOOK_TOPICS = [
|
|
|
68
60
|
"coupon.created", "coupon.updated", "coupon.deleted",
|
|
69
61
|
];
|
|
70
62
|
|
|
71
|
-
export const SHIPPING_METHOD_TYPES = [
|
|
72
|
-
{ value: "flat_rate", label: "Flat rate" },
|
|
73
|
-
{ value: "free_shipping", label: "Free shipping" },
|
|
74
|
-
{ value: "local_pickup", label: "Local pickup" },
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
export const FREE_SHIPPING_REQUIRES = [
|
|
78
|
-
{ value: "", label: "No requirement" },
|
|
79
|
-
{ value: "coupon", label: "A valid free shipping coupon" },
|
|
80
|
-
{ value: "min_amount", label: "A minimum order amount" },
|
|
81
|
-
{ value: "either", label: "A minimum order amount OR a coupon" },
|
|
82
|
-
{ value: "both", label: "A minimum order amount AND a coupon" },
|
|
83
|
-
];
|
|
84
|
-
|
|
85
63
|
/**
|
|
86
64
|
* The 11 transactional emails.
|
|
87
65
|
* `recipient: "admin"` emails expose a Recipient override in the Emails settings.
|
|
@@ -91,6 +69,8 @@ export const EMAIL_TYPES = [
|
|
|
91
69
|
{ id: "new_order", label: "New order", recipient: "admin" },
|
|
92
70
|
{ id: "cancelled_order", label: "Cancelled order", recipient: "admin" },
|
|
93
71
|
{ id: "failed_order", label: "Failed order", recipient: "admin" },
|
|
72
|
+
{ id: "low_stock", label: "Low stock", recipient: "admin", stock: true },
|
|
73
|
+
{ id: "out_of_stock", label: "Out of stock", recipient: "admin", stock: true },
|
|
94
74
|
{ id: "on_hold_order", label: "Order on-hold", recipient: "customer" },
|
|
95
75
|
{ id: "processing_order", label: "Processing order", recipient: "customer" },
|
|
96
76
|
{ id: "completed_order", label: "Completed order", recipient: "customer" },
|
|
@@ -135,13 +115,6 @@ export const CURRENCIES = [
|
|
|
135
115
|
{ code: "VND", name: "Vietnamese Dong", symbol: "₫", decimals: 0 },
|
|
136
116
|
];
|
|
137
117
|
|
|
138
|
-
export const CURRENCY_POSITIONS = [
|
|
139
|
-
{ value: "left", label: "Left ($99.99)" },
|
|
140
|
-
{ value: "right", label: "Right (99.99$)" },
|
|
141
|
-
{ value: "left_space", label: "Left with space ($ 99.99)" },
|
|
142
|
-
{ value: "right_space", label: "Right with space (99.99 $)" },
|
|
143
|
-
];
|
|
144
|
-
|
|
145
118
|
export const WEIGHT_UNITS = ["kg", "g", "lbs", "oz"];
|
|
146
119
|
export const DIMENSION_UNITS = ["cm", "m", "mm", "in", "yd"];
|
|
147
120
|
|