@base44/app-plugin-commerce 0.1.15 → 0.1.16
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 +10 -7
- package/base44/functions/commerce/seed-store/entry.ts +23 -18
- package/base44/functions/commerce/seed-store/seed-catalog.ts +150 -77
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +8 -2
- package/skills/commerce/SKILL.md +8 -8
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +287 -263
- package/skills/commerce/references/online-payments.md +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +216 -0
- package/src/commerce/storefront/address.js +70 -0
- package/src/commerce/storefront/index.js +51 -0
- package/src/commerce/storefront/pickers.jsx +90 -0
- package/src/commerce/storefront/useCheckout.jsx +279 -0
- package/src/commerce/storefront/useOrderReturn.js +56 -0
- package/src/commerce/utils/index.js +6 -0
- package/src/commerce/utils/storefront.js +9 -2
package/README.md
CHANGED
|
@@ -8,10 +8,11 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
|
|
|
8
8
|
|
|
9
9
|
- **20 entities** — Products (a product sells variants when it carries attributes; no type field), variations, categories, ribbons, attributes + values, reviews, orders (embedded line/shipping/tax/fee/coupon lines), order notes, refunds, coupons, customers, Shipping & Tax Locations (shipping rates + tax groups per location), payment gateways, store settings, webhooks + deliveries, carts, download permissions, email log.
|
|
10
10
|
- **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store` — one call seeds the business defaults **and the whole catalog** (products with attributes in, variants/categories/taxonomy created internally).
|
|
11
|
-
- **Online card payments, order side premade** — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook, both premade) and refund records are all built. Wiring a provider (Stripe, PayPal, a local PSP…) means implementing **four functions in one file** — `base44/shared/commerce/card-payment.ts` — and nothing else; a complete Stripe implementation to paste in ships in [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §
|
|
11
|
+
- **Online card payments, order side premade** — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook, both premade) and refund records are all built. Wiring a provider (Stripe, PayPal, a local PSP…) means implementing **four functions in one file** — `base44/shared/commerce/card-payment.ts` — and nothing else; a complete Stripe implementation to paste in ships in [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §4. Every other payment option is manual (on-hold + instructions) and needs no code; the admin can add more in Settings → Payments. See [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md).
|
|
12
12
|
- **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
|
|
13
13
|
- **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
|
|
14
14
|
- **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and variant price ranges; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" copy states a configured rule rather than an invented number. See [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md).
|
|
15
|
+
- **Storefront React layer** (`src/commerce/storefront/`) — hooks and headless (render-prop) helpers for the parts of a shopfront whose contracts are the same in every store, with **zero visual components** (the look stays yours): `StorefrontProvider` (one client, cached store info, ONE shared cart), `useCart` (shared cart state, serialized mutations), `useCheckout` (guided checkout — automatic debounced shipping/tax recalculation once the address is complete, shipping/payment choice, a `canPlaceOrder` gate with named blockers, redirect-handling `placeOrder`), `ShippingMethodPicker`/`PaymentMethodPicker` (headless), and `useOrderReturn` (the mandatory `/order-received` page in one hook). Needs React and nothing else.
|
|
15
16
|
- **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
|
|
16
17
|
- **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/), which holds [`SKILL.md`](./skills/commerce/SKILL.md) (the short map agents start from), [`installation-guidelines.md`](./skills/commerce/installation-guidelines.md), [`post-installation.md`](./skills/commerce/post-installation.md), per-topic guides in [`references/`](./skills/commerce/references/) and the API references in [`docs/`](./skills/commerce/docs/) — the whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
|
|
17
18
|
|
|
@@ -29,8 +30,10 @@ base44-commerce-template/
|
|
|
29
30
|
│ └── commerce/ commerce engine + static data (bundled into every function)
|
|
30
31
|
├── src/
|
|
31
32
|
│ └── commerce/
|
|
32
|
-
│ ├── admin/
|
|
33
|
-
│
|
|
33
|
+
│ ├── admin/ React admin UI (copy into your app's src/commerce/)
|
|
34
|
+
│ ├── utils/ storefront helpers — API client, variant selection, shipping promos
|
|
35
|
+
│ └── storefront/ storefront React layer — shared-cart provider, guided checkout,
|
|
36
|
+
│ headless pickers, order-received hook (logic only, no visuals)
|
|
34
37
|
├── scripts/
|
|
35
38
|
│ └── install.js static installer (run from <app>/examples/commerce/scripts/)
|
|
36
39
|
├── skills/
|
|
@@ -72,7 +75,7 @@ From your existing Base44 app:
|
|
|
72
75
|
npx base44 functions deploy
|
|
73
76
|
npx base44 agents push
|
|
74
77
|
```
|
|
75
|
-
3. **Copy the UI files:** `src/commerce/admin/` → `src/commerce/admin/`
|
|
78
|
+
3. **Copy the UI files:** `src/commerce/admin/` → `src/commerce/admin/`, `src/commerce/utils/` → `src/commerce/utils/` and `src/commerce/storefront/` → `src/commerce/storefront/`.
|
|
76
79
|
4. **Confirm UI deps** — `sonner`, `recharts` and `react-markdown` all ship with the default Base44 template and are the only ones the admin needs, so usually there is **nothing to install**. Read your app's `package.json` first and install *only* the names missing from it — never re-install a package that is already a dependency:
|
|
77
80
|
```bash
|
|
78
81
|
grep -E '"(sonner|recharts|react-markdown)"' package.json # all three listed → skip the install
|
|
@@ -94,12 +97,12 @@ If you build on Base44's hosted platform, use the Base44 agent/MCP to write the
|
|
|
94
97
|
|
|
95
98
|
1. Copy this whole repo into the target app at `examples/commerce/` (e.g. download + extract a tarball with `run_command`), then run `node examples/commerce/scripts/install.js` via `run_command` — or use `write_file` to copy every file under `base44/` and `src/commerce/` individually (use `list_directory`/`read_file` to adapt to the app's actual layout — e.g. the `@/api/base44Client` path and your router file).
|
|
96
99
|
2. Wait for the app to build (`get_app_status`), then confirm entities exist (`list_entity_schemas`).
|
|
97
|
-
3. Grant your user the `admin` role, then seed the store's data — one `commerce/seed-store` call takes the whole catalog via `products`, or `with_sample_data: true` for the demo catalog; leave both out for defaults only, or skip the call for the admin's first-run **Initialize store defaults** screen ([`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §
|
|
100
|
+
3. Grant your user the `admin` role, then seed the store's data — one `commerce/seed-store` call takes the whole catalog via `products`, or `with_sample_data: true` for the demo catalog; leave both out for defaults only, or skip the call for the admin's first-run **Initialize store defaults** screen ([`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §3).
|
|
98
101
|
|
|
99
102
|
## What's NOT included
|
|
100
103
|
|
|
101
|
-
- **No visitor/storefront UI.** The storefront **API** is complete (`commerce/storefront-*` functions); building the shopfront is up to you — see [`skills/commerce/docs/api-storefront.md`](./skills/commerce/docs/api-storefront.md). What *does* ship for the storefront is **
|
|
102
|
-
- **No payment provider** — the order side of card payments is premade (see above), but charging a card needs a provider: implement the four functions in `base44/shared/commerce/card-payment.ts` to wire one (Stripe: paste-in in `skills/commerce/post-installation.md` §
|
|
104
|
+
- **No visitor/storefront UI.** The storefront **API** is complete (`commerce/storefront-*` functions); building the shopfront is up to you — see [`skills/commerce/docs/api-storefront.md`](./skills/commerce/docs/api-storefront.md). What *does* ship for the storefront is **logic, not looks**: [`src/commerce/utils/`](./src/commerce/utils/) (framework-free API client + variant-selection functions — map a Size/Color selection to a `ProductVariation` and back, per-option availability, variant price ranges) and [`src/commerce/storefront/`](./src/commerce/storefront/) (React hooks + headless pickers for the shared cart, the guided checkout with automatic shipping/tax recalculation, and the `/order-received` page — no visual components), plus [`skills/commerce/references/product-render.md`](./skills/commerce/references/product-render.md) (what to render in a grid vs. a product page, and which fields each call returns) and [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md), the variant rules that go with the helpers.
|
|
105
|
+
- **No payment provider** — the order side of card payments is premade (see above), but charging a card needs a provider: implement the four functions in `base44/shared/commerce/card-payment.ts` to wire one (Stripe: paste-in in `skills/commerce/post-installation.md` §4). Until then the card option answers "card payments are not available yet" at checkout (or switch it off in Settings → Payments), and the manual gateways (bank transfer, cash on delivery, anything the admin adds) carry checkout.
|
|
103
106
|
- **No scheduled workflows shipped.** Base44 *does* have a scheduler, but this template ships no workflow files — time-based jobs (stock-hold release, cart expiry, webhook-log pruning) run **opportunistically** where possible, and for the rest you (or the Base44 agent) create scheduled workflows that call `commerce/admin-tools`/`commerce/admin-orders` actions — see *Scheduled work* in [`skills/commerce/SKILL.md`](./skills/commerce/SKILL.md).
|
|
104
107
|
|
|
105
108
|
## Next steps
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* variations generated from `attributes` when not given, per-product skip when
|
|
13
13
|
* the sku/slug already exists) or the generic demo catalog
|
|
14
14
|
* (with_sample_data=true, only when the store has zero products) — with
|
|
15
|
-
* best-effort rollback on mid-failure.
|
|
15
|
+
* best-effort rollback on mid-failure. Independent writes run in parallel at
|
|
16
|
+
* every stage (canaries, defaults, and the whole catalog pipeline), so the
|
|
17
|
+
* call is bounded by its longest dependency chain rather than its call count.
|
|
18
|
+
* See seed-catalog.ts for the pipeline and the concurrency caps.
|
|
16
19
|
*
|
|
17
20
|
* Body: { store_name?, currency?, weight_unit?, dimension_unit?,
|
|
18
21
|
* payment_methods?, with_sample_data?, products?, coupons?, locations? }
|
|
@@ -130,16 +133,16 @@ Deno.serve(async (req) => {
|
|
|
130
133
|
if (weightUnit) generalPatch.weight_unit = weightUnit;
|
|
131
134
|
if (dimensionUnit) generalPatch.dimension_unit = dimensionUnit;
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
const missingGroups = SETTINGS_DEFAULTS.filter((group) => !existingGroups.has(group.group_id));
|
|
137
|
+
await Promise.all(missingGroups.map((group) => {
|
|
135
138
|
const values = group.group_id === "emails"
|
|
136
139
|
? { ...group.values, store_name: storeName }
|
|
137
140
|
: group.group_id === "general"
|
|
138
141
|
? { ...group.values, ...generalPatch }
|
|
139
142
|
: group.values;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
return sr.entities["commerce.StoreSettings"].create({ ...group, values });
|
|
144
|
+
}));
|
|
145
|
+
seeded.settings_groups = missingGroups.length;
|
|
143
146
|
|
|
144
147
|
// Explicit currency/units always win — unlike store_name there is no
|
|
145
148
|
// "blank" to distinguish a merchant's choice from the seeded default, and a
|
|
@@ -185,14 +188,14 @@ Deno.serve(async (req) => {
|
|
|
185
188
|
});
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
|
|
191
|
+
await Promise.all(GATEWAY_DEFAULTS.map(async (gw) => {
|
|
189
192
|
const hits = (await sr.entities["commerce.PaymentGateway"].filter({ slug: gw.slug }, undefined, 1)) ?? [];
|
|
190
|
-
if (hits.length)
|
|
193
|
+
if (hits.length) return;
|
|
191
194
|
await sr.entities["commerce.PaymentGateway"].create(
|
|
192
195
|
methodSlugs ? { ...gw, enabled: methodSlugs.includes(gw.slug) } : gw,
|
|
193
196
|
);
|
|
194
197
|
seeded.gateways++;
|
|
195
|
-
}
|
|
198
|
+
}));
|
|
196
199
|
|
|
197
200
|
// Explicit payment_methods always win, like currency: converge EVERY
|
|
198
201
|
// gateway row's enabled flag to the requested set, so "card-only" is one
|
|
@@ -201,13 +204,13 @@ Deno.serve(async (req) => {
|
|
|
201
204
|
if (methodSlugs) {
|
|
202
205
|
const rows = (await sr.entities["commerce.PaymentGateway"].list(undefined, 100)) ?? [];
|
|
203
206
|
paymentMethods = { enabled: [], disabled: [] };
|
|
204
|
-
|
|
205
|
-
const want = methodSlugs
|
|
207
|
+
await Promise.all(rows.map(async (row: any) => {
|
|
208
|
+
const want = methodSlugs!.includes(String(row.slug));
|
|
206
209
|
if (Boolean(row.enabled) !== want) {
|
|
207
210
|
await sr.entities["commerce.PaymentGateway"].update(row.id, { enabled: want });
|
|
208
211
|
}
|
|
209
|
-
(want ? paymentMethods
|
|
210
|
-
}
|
|
212
|
+
(want ? paymentMethods!.enabled : paymentMethods!.disabled).push(String(row.slug));
|
|
213
|
+
}));
|
|
211
214
|
}
|
|
212
215
|
|
|
213
216
|
// Caller-supplied `locations` ARE the store's shipping story — seeding the
|
|
@@ -276,20 +279,22 @@ function canarySpecs(needsCatalog: boolean): Array<{ entity: string; record: Rec
|
|
|
276
279
|
return needsCatalog ? [...base, ...catalog] : base;
|
|
277
280
|
}
|
|
278
281
|
|
|
282
|
+
/** Probes are independent, so they all run concurrently — the canary stage
|
|
283
|
+
* costs one create+delete round-trip instead of one per entity. */
|
|
279
284
|
async function runCanaries(sr: any, needsCatalog: boolean): Promise<Array<{ entity: string; error: string }>> {
|
|
280
|
-
const
|
|
281
|
-
for (const spec of canarySpecs(needsCatalog)) {
|
|
285
|
+
const outcomes = await Promise.all(canarySpecs(needsCatalog).map(async (spec) => {
|
|
282
286
|
let created: any = null;
|
|
283
287
|
try {
|
|
284
288
|
created = await sr.entities[spec.entity].create(spec.record);
|
|
289
|
+
return null;
|
|
285
290
|
} catch (e) {
|
|
286
|
-
|
|
291
|
+
return { entity: spec.entity, error: String((e as Error)?.message ?? e) };
|
|
287
292
|
} finally {
|
|
288
293
|
if (created?.id) {
|
|
289
294
|
try { await sr.entities[spec.entity].delete(created.id); } catch { /* orphaned canary is harmless */ }
|
|
290
295
|
}
|
|
291
296
|
}
|
|
292
|
-
}
|
|
293
|
-
return
|
|
297
|
+
}));
|
|
298
|
+
return outcomes.filter(Boolean) as Array<{ entity: string; error: string }>;
|
|
294
299
|
}
|
|
295
300
|
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
bumpCount,
|
|
26
26
|
derivePricing,
|
|
27
27
|
deriveStock,
|
|
28
|
-
ensureUniqueSlug,
|
|
29
28
|
rollUpParent,
|
|
30
29
|
slugify,
|
|
31
30
|
} from "../../../shared/commerce/catalog.ts";
|
|
@@ -77,7 +76,7 @@ const MAX_PRODUCTS = 100;
|
|
|
77
76
|
const MAX_COUPONS = 50;
|
|
78
77
|
const MAX_LOCATIONS = 50;
|
|
79
78
|
const MAX_VARIATIONS_PER_PRODUCT = 50; // also the cartesian-explosion guard
|
|
80
|
-
const MAX_TOTAL_VARIATIONS = 500; // every variation is
|
|
79
|
+
const MAX_TOTAL_VARIATIONS = 500; // every variation is its own create call
|
|
81
80
|
|
|
82
81
|
const PRODUCT_KEYS = new Set([
|
|
83
82
|
// spec-only keys
|
|
@@ -504,6 +503,48 @@ export function sampleCatalog(): CatalogSpec {
|
|
|
504
503
|
};
|
|
505
504
|
}
|
|
506
505
|
|
|
506
|
+
// ── parallelism ──────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Concurrency-limited map that NEVER abandons in-flight work: when a task
|
|
510
|
+
* fails, the remaining workers stop picking up new items, every call already
|
|
511
|
+
* started settles, and only then does the first error throw. seedCatalog's
|
|
512
|
+
* rollback depends on this — it must see every record that was actually
|
|
513
|
+
* created before it starts deleting. Results keep input order.
|
|
514
|
+
*/
|
|
515
|
+
async function pMap<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
516
|
+
const out = new Array<R>(items.length);
|
|
517
|
+
let next = 0;
|
|
518
|
+
let failed = false;
|
|
519
|
+
let firstError: unknown;
|
|
520
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
521
|
+
while (!failed) {
|
|
522
|
+
const i = next++;
|
|
523
|
+
if (i >= items.length) return;
|
|
524
|
+
try {
|
|
525
|
+
out[i] = await fn(items[i], i);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
if (!failed) {
|
|
528
|
+
failed = true;
|
|
529
|
+
firstError = e;
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
await Promise.all(workers); // workers never reject — they record the first error
|
|
536
|
+
if (failed) throw firstError;
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Each stage runs its independent API calls concurrently: seeding time is
|
|
541
|
+
// dominated by call round-trips, and almost none of them depend on each other.
|
|
542
|
+
// The caps keep a big catalog from opening hundreds of simultaneous requests:
|
|
543
|
+
// at most PRODUCT_CONCURRENCY × VARIATION_CONCURRENCY calls are in flight.
|
|
544
|
+
const SEED_CONCURRENCY = 8; // reads/creates within a flat stage
|
|
545
|
+
const PRODUCT_CONCURRENCY = 4; // products in flight (each fans out its variations)
|
|
546
|
+
const VARIATION_CONCURRENCY = 5; // variation creates in flight per product
|
|
547
|
+
|
|
507
548
|
// ── seeding ──────────────────────────────────────────────────────────────────
|
|
508
549
|
|
|
509
550
|
/**
|
|
@@ -512,9 +553,13 @@ export function sampleCatalog(): CatalogSpec {
|
|
|
512
553
|
* skipped instead of duplicated, so a retrying caller converges. The sample
|
|
513
554
|
* path passes false — it only ever runs against a store with zero products.
|
|
514
555
|
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
556
|
+
* Writes are parallelized per stage (see the concurrency caps above); records
|
|
557
|
+
* that must not race — same-name slugs, shared ribbons — are claimed in-call
|
|
558
|
+
* before any await, so parallel product tasks can never double-create them.
|
|
559
|
+
*
|
|
560
|
+
* On mid-write failure every record this call created is deleted and the
|
|
561
|
+
* error surfaces as `errorCode`; taxonomy that pre-existed is never touched
|
|
562
|
+
* by the rollback.
|
|
518
563
|
*/
|
|
519
564
|
export async function seedCatalog(
|
|
520
565
|
sr: any,
|
|
@@ -538,9 +583,8 @@ export async function seedCatalog(
|
|
|
538
583
|
const inventory = (await getSettings(sr, "inventory")).inventory ?? {};
|
|
539
584
|
const outThreshold = Number(inventory.out_of_stock_threshold ?? 0);
|
|
540
585
|
|
|
541
|
-
// ── decide per product before writing anything
|
|
542
|
-
const plan
|
|
543
|
-
for (const p of spec.products) {
|
|
586
|
+
// ── decide per product before writing anything (read-only, parallel) ───
|
|
587
|
+
const plan = await pMap(spec.products, SEED_CONCURRENCY, async (p) => {
|
|
544
588
|
let skip: Record<string, any> | undefined;
|
|
545
589
|
if (opts.skipExisting) {
|
|
546
590
|
const sku = p.fields.sku;
|
|
@@ -556,16 +600,15 @@ export async function seedCatalog(
|
|
|
556
600
|
}
|
|
557
601
|
if (!skip) {
|
|
558
602
|
// explicit variation skus fail as 409 duplicate_sku here, before any write
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
603
|
+
const withSku = p.variations.filter((v) => v.fields.sku);
|
|
604
|
+
await pMap(withSku, VARIATION_CONCURRENCY, (v) => assertUniqueSku(sr, String(v.fields.sku), {}));
|
|
562
605
|
}
|
|
563
|
-
|
|
564
|
-
}
|
|
606
|
+
return { p, skip };
|
|
607
|
+
});
|
|
565
608
|
|
|
566
609
|
// ── taxonomy (get-or-create; reused records are never rolled back) ─────
|
|
567
610
|
const categoryBySlug: Record<string, any> = {};
|
|
568
|
-
|
|
611
|
+
await pMap(spec.categories, SEED_CONCURRENCY, async (def) => {
|
|
569
612
|
const hits = (await sr.entities["commerce.ProductCategory"].filter({ slug: def.slug }, undefined, 1)) ?? [];
|
|
570
613
|
if (hits.length) {
|
|
571
614
|
categoryBySlug[def.slug] = hits[0];
|
|
@@ -574,50 +617,68 @@ export async function seedCatalog(
|
|
|
574
617
|
categoryBySlug[def.slug] = await track("commerce.ProductCategory", { description: "", display: "default", menu_order: 0, ...def, count: 0 });
|
|
575
618
|
counts.categories.created++;
|
|
576
619
|
}
|
|
577
|
-
}
|
|
620
|
+
});
|
|
578
621
|
|
|
579
|
-
|
|
622
|
+
// Ribbons are resolved up front — the products below write in parallel, so
|
|
623
|
+
// a lazy get-or-create would let two products race the same new ribbon
|
|
624
|
+
// into duplicates. One name, one record, resolved exactly once.
|
|
580
625
|
const ribbonByLower: Record<string, any> = {};
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (ribbon) counts.ribbons.reused++;
|
|
587
|
-
else {
|
|
588
|
-
ribbon = await track("commerce.ProductRibbon", { name: ribbonName, count: 0 });
|
|
589
|
-
counts.ribbons.created++;
|
|
626
|
+
const ribbonNames = new Map<string, string>(); // lower → declared casing (first wins)
|
|
627
|
+
for (const { p, skip } of plan) {
|
|
628
|
+
if (skip) continue;
|
|
629
|
+
for (const name of p.ribbonNames) {
|
|
630
|
+
if (!ribbonNames.has(name.toLowerCase())) ribbonNames.set(name.toLowerCase(), name);
|
|
590
631
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
632
|
+
}
|
|
633
|
+
if (ribbonNames.size) {
|
|
634
|
+
const allRibbons = await scanAll(sr.entities["commerce.ProductRibbon"], null, "name");
|
|
635
|
+
await pMap([...ribbonNames.values()], SEED_CONCURRENCY, async (name) => {
|
|
636
|
+
const key = name.toLowerCase();
|
|
637
|
+
const existing = allRibbons.find((t: any) => String(t.name ?? "").toLowerCase() === key);
|
|
638
|
+
if (existing) {
|
|
639
|
+
ribbonByLower[key] = existing;
|
|
640
|
+
counts.ribbons.reused++;
|
|
641
|
+
} else {
|
|
642
|
+
ribbonByLower[key] = await track("commerce.ProductRibbon", { name, count: 0 });
|
|
643
|
+
counts.ribbons.created++;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
594
647
|
|
|
595
|
-
|
|
648
|
+
// Attributes and terms: `order` values are pre-assigned from one read, so
|
|
649
|
+
// the creates themselves can run concurrently without racing the counter.
|
|
596
650
|
const attributeByCode: Record<string, any> = {};
|
|
597
651
|
const termsByAttrId: Record<string, any[]> = {};
|
|
598
|
-
|
|
599
|
-
allAttributes
|
|
600
|
-
let
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, undefined, 500)) ?? [];
|
|
611
|
-
let maxTermOrder = terms.reduce((m: number, t: any) => Math.max(m, Number(t.order ?? 0)), -1);
|
|
612
|
-
for (const term of spec.termsByCode[def.code] ?? []) {
|
|
613
|
-
if (terms.some((t: any) => String(t.name ?? "").toLowerCase() === term.name.toLowerCase())) {
|
|
614
|
-
counts.terms.reused++;
|
|
615
|
-
continue;
|
|
652
|
+
if (spec.attributes.length) {
|
|
653
|
+
const allAttributes = await scanAll(sr.entities["commerce.ProductAttribute"], null, "order");
|
|
654
|
+
let nextOrder = allAttributes.reduce((m: number, a: any) => Math.max(m, Number(a.order ?? 0)), -1) + 1;
|
|
655
|
+
const missingAttrs: Array<{ def: { name: string; code: string }; order: number }> = [];
|
|
656
|
+
for (const def of spec.attributes) {
|
|
657
|
+
const attr = allAttributes.find((a: any) => a.code === def.code);
|
|
658
|
+
if (attr) {
|
|
659
|
+
attributeByCode[def.code] = attr;
|
|
660
|
+
counts.attributes.reused++;
|
|
661
|
+
} else {
|
|
662
|
+
missingAttrs.push({ def, order: nextOrder++ });
|
|
616
663
|
}
|
|
617
|
-
terms.push(await track("commerce.ProductAttributeTerm", { attribute_id: attr.id, name: term.name, order: ++maxTermOrder, count: 0 }));
|
|
618
|
-
counts.terms.created++;
|
|
619
664
|
}
|
|
620
|
-
|
|
665
|
+
await pMap(missingAttrs, SEED_CONCURRENCY, async ({ def, order }) => {
|
|
666
|
+
attributeByCode[def.code] = await track("commerce.ProductAttribute", { name: def.name, code: def.code, order });
|
|
667
|
+
counts.attributes.created++;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
await pMap(spec.attributes, SEED_CONCURRENCY, async (def) => {
|
|
671
|
+
const attr = attributeByCode[def.code];
|
|
672
|
+
const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, undefined, 500)) ?? [];
|
|
673
|
+
const maxTermOrder = terms.reduce((m: number, t: any) => Math.max(m, Number(t.order ?? 0)), -1);
|
|
674
|
+
const wanted = spec.termsByCode[def.code] ?? [];
|
|
675
|
+
const missing = wanted.filter((term) => !terms.some((t: any) => String(t.name ?? "").toLowerCase() === term.name.toLowerCase()));
|
|
676
|
+
counts.terms.reused += wanted.length - missing.length;
|
|
677
|
+
const createdTerms = await pMap(missing, VARIATION_CONCURRENCY, (term, i) =>
|
|
678
|
+
track("commerce.ProductAttributeTerm", { attribute_id: attr.id, name: term.name, order: maxTermOrder + 1 + i, count: 0 }));
|
|
679
|
+
counts.terms.created += createdTerms.length;
|
|
680
|
+
termsByAttrId[attr.id] = [...terms, ...createdTerms];
|
|
681
|
+
});
|
|
621
682
|
}
|
|
622
683
|
|
|
623
684
|
// References are by name, so payload casing must never fork an existing
|
|
@@ -628,8 +689,23 @@ export async function seedCatalog(
|
|
|
628
689
|
return term ? term.name : option;
|
|
629
690
|
};
|
|
630
691
|
|
|
631
|
-
//
|
|
632
|
-
|
|
692
|
+
// Slugs are claimed in-call before any await, so two same-named products
|
|
693
|
+
// seeding concurrently can never race `ensureUniqueSlug` to one slug.
|
|
694
|
+
const takenSlugs = new Set<string>();
|
|
695
|
+
const claimSlug = async (base: string): Promise<string> => {
|
|
696
|
+
for (let i = 1; i < 100; i++) {
|
|
697
|
+
const candidate = i === 1 ? base : `${base}-${i}`;
|
|
698
|
+
if (takenSlugs.has(candidate)) continue;
|
|
699
|
+
takenSlugs.add(candidate); // claim synchronously, then verify against the DB
|
|
700
|
+
const hits = (await sr.entities["commerce.Product"].filter({ slug: candidate }, undefined, 1)) ?? [];
|
|
701
|
+
if (!hits.length) return candidate;
|
|
702
|
+
}
|
|
703
|
+
const fallback = `${base}-${crypto.randomUUID().slice(0, 6)}`;
|
|
704
|
+
takenSlugs.add(fallback);
|
|
705
|
+
return fallback;
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// ── products + variations (parallel; results keep payload order) ───────
|
|
633
709
|
const countDeltas = new Map<string, number>(); // "entity|id" → +n
|
|
634
710
|
const bumpLater = (entity: string, id: string) => {
|
|
635
711
|
const key = `${entity}|${id}`;
|
|
@@ -637,21 +713,17 @@ export async function seedCatalog(
|
|
|
637
713
|
};
|
|
638
714
|
let variationsCreated = 0;
|
|
639
715
|
|
|
640
|
-
|
|
641
|
-
if (skip)
|
|
642
|
-
results.push(skip);
|
|
643
|
-
continue;
|
|
644
|
-
}
|
|
716
|
+
const results = await pMap(plan, PRODUCT_CONCURRENCY, async ({ p, skip }) => {
|
|
717
|
+
if (skip) return skip;
|
|
645
718
|
const record: any = {
|
|
646
719
|
status: "publish",
|
|
647
|
-
ribbon_ids: [],
|
|
720
|
+
ribbon_ids: p.ribbonNames.map((name) => ribbonByLower[name.toLowerCase()]?.id).filter(Boolean),
|
|
648
721
|
meta_data: [],
|
|
649
722
|
total_sales: 0,
|
|
650
723
|
...p.fields,
|
|
651
724
|
category_ids: p.categorySlugs.map((slug) => categoryBySlug[slug]?.id).filter(Boolean),
|
|
652
725
|
};
|
|
653
|
-
|
|
654
|
-
record.slug = await ensureUniqueSlug(sr, slugify(record.slug || record.name));
|
|
726
|
+
record.slug = await claimSlug(slugify(record.slug || record.name));
|
|
655
727
|
if (record.manage_stock === undefined && record.stock_quantity != null) record.manage_stock = true;
|
|
656
728
|
|
|
657
729
|
if (p.attributes.length) {
|
|
@@ -675,8 +747,7 @@ export async function seedCatalog(
|
|
|
675
747
|
deriveStock(record, outThreshold, !!record.manage_stock);
|
|
676
748
|
const product = await track("commerce.Product", record);
|
|
677
749
|
|
|
678
|
-
const seededVariations
|
|
679
|
-
for (const v of p.variations) {
|
|
750
|
+
const seededVariations = await pMap(p.variations, VARIATION_CONCURRENCY, async (v) => {
|
|
680
751
|
const rec: any = {
|
|
681
752
|
status: "publish",
|
|
682
753
|
...v.fields,
|
|
@@ -701,46 +772,47 @@ export async function seedCatalog(
|
|
|
701
772
|
rec.manage_stock = rec.stock_quantity != null ? "yes" : "parent";
|
|
702
773
|
derivePricing(rec);
|
|
703
774
|
deriveStock(rec, outThreshold, rec.manage_stock === "yes");
|
|
704
|
-
|
|
775
|
+
const createdVariation = await track("commerce.ProductVariation", rec);
|
|
705
776
|
variationsCreated++;
|
|
706
|
-
|
|
777
|
+
return createdVariation;
|
|
778
|
+
});
|
|
707
779
|
if (seededVariations.length) await rollUpParent(sr, product, seededVariations);
|
|
708
780
|
|
|
709
781
|
for (const id of record.category_ids) bumpLater("commerce.ProductCategory", id);
|
|
710
782
|
for (const id of record.ribbon_ids) bumpLater("commerce.ProductRibbon", id);
|
|
711
|
-
|
|
712
|
-
}
|
|
783
|
+
return { name: product.name, id: product.id, slug: product.slug, sku: product.sku ?? "", variation_count: seededVariations.length };
|
|
784
|
+
});
|
|
713
785
|
|
|
714
786
|
// deferred so a mid-creation rollback never leaves counts drifted;
|
|
715
787
|
// a failure from here on is count-only and recount-terms repairs it
|
|
716
|
-
|
|
788
|
+
await pMap([...countDeltas], SEED_CONCURRENCY, ([key, delta]) => {
|
|
717
789
|
const [entity, id] = key.split("|");
|
|
718
|
-
|
|
719
|
-
}
|
|
790
|
+
return bumpCount(sr, entity, id, delta);
|
|
791
|
+
});
|
|
720
792
|
|
|
721
|
-
// ── coupons +
|
|
793
|
+
// ── coupons + locations (skip-if-exists; codes/names are payload-unique) ─
|
|
722
794
|
const couponCounts = { created: 0, skipped: 0 };
|
|
723
|
-
|
|
795
|
+
await pMap(spec.coupons, SEED_CONCURRENCY, async (c) => {
|
|
724
796
|
const code = String(c.code).trim().toLowerCase();
|
|
725
797
|
const hits = (await sr.entities["commerce.Coupon"].filter({ code }, undefined, 1)) ?? [];
|
|
726
798
|
if (hits.length) {
|
|
727
799
|
couponCounts.skipped++;
|
|
728
|
-
|
|
800
|
+
return;
|
|
729
801
|
}
|
|
730
802
|
await track("commerce.Coupon", { discount_type: "fixed_cart", usage_count: 0, used_by: [], ...c, code });
|
|
731
803
|
couponCounts.created++;
|
|
732
|
-
}
|
|
804
|
+
});
|
|
733
805
|
|
|
734
806
|
const locationCounts = { created: 0, skipped: 0 };
|
|
735
|
-
|
|
807
|
+
await pMap(spec.locations, SEED_CONCURRENCY, async (loc) => {
|
|
736
808
|
const hits = (await sr.entities["commerce.ShippingTaxLocation"].filter({ name: loc.name }, undefined, 1)) ?? [];
|
|
737
809
|
if (hits.length) {
|
|
738
810
|
locationCounts.skipped++;
|
|
739
|
-
|
|
811
|
+
return;
|
|
740
812
|
}
|
|
741
813
|
await track("commerce.ShippingTaxLocation", loc);
|
|
742
814
|
locationCounts.created++;
|
|
743
|
-
}
|
|
815
|
+
});
|
|
744
816
|
|
|
745
817
|
return {
|
|
746
818
|
categories: counts.categories,
|
|
@@ -755,7 +827,8 @@ export async function seedCatalog(
|
|
|
755
827
|
products: results,
|
|
756
828
|
};
|
|
757
829
|
} catch (e) {
|
|
758
|
-
//
|
|
830
|
+
// Best-effort rollback, most-recently-created first. pMap drains every
|
|
831
|
+
// in-flight create before the error reaches here, so `created` is complete.
|
|
759
832
|
for (const { entity, id } of created.reverse()) {
|
|
760
833
|
try {
|
|
761
834
|
await sr.entities[entity].delete(id);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* parseWebhook → validate the provider's server-to-server event
|
|
10
10
|
*
|
|
11
11
|
* Any provider works; a ready-made reference implementation (Stripe) is in
|
|
12
|
-
* `.agents/skills/commerce/post-installation.md` §
|
|
12
|
+
* `.agents/skills/commerce/post-installation.md` §4 to paste over this
|
|
13
13
|
* file — another provider implements the same functions against its own API.
|
|
14
14
|
*
|
|
15
15
|
* REPLACE THIS FILE WHOLE (one write of the full new content). Never
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (create a payment page, check it was paid, refund it, validate a webhook
|
|
7
7
|
* event). Any provider works;
|
|
8
8
|
* a reference implementation (Stripe) is in
|
|
9
|
-
* `.agents/skills/commerce/post-installation.md` §
|
|
9
|
+
* `.agents/skills/commerce/post-installation.md` §4, and the
|
|
10
10
|
* provider-agnostic rules are in
|
|
11
11
|
* `.agents/skills/commerce/references/online-payments.md`.
|
|
12
12
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
package/scripts/install.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* ../base44/agents/commerce/ → ../../../base44/agents/commerce/
|
|
19
19
|
* ../src/commerce/admin/ → ../../../src/commerce/admin/
|
|
20
20
|
* ../src/commerce/utils/ → ../../../src/commerce/utils/
|
|
21
|
+
* ../src/commerce/storefront/ → ../../../src/commerce/storefront/
|
|
21
22
|
* ../skills/commerce/ → ../../../.agents/skills/commerce/
|
|
22
23
|
*
|
|
23
24
|
* Merge semantics: directories are merged — files owned by the template are
|
|
@@ -164,6 +165,10 @@
|
|
|
164
165
|
// Storefront helpers (variant selection) — framework-free, for the
|
|
165
166
|
// customer-facing UI the app builds itself.
|
|
166
167
|
{ label: "utils", from: ["src", "commerce", "utils"], to: ["src", "commerce", "utils"] },
|
|
168
|
+
// Storefront React layer — shared-cart provider, guided-checkout hook,
|
|
169
|
+
// headless shipping/payment pickers, order-received hook. Logic only, no
|
|
170
|
+
// visual components: the shopfront's look stays the app's to design.
|
|
171
|
+
{ label: "storefront", from: ["src", "commerce", "storefront"], to: ["src", "commerce", "storefront"] },
|
|
167
172
|
// Commerce skill — SKILL.md, install/post-install guides, references/
|
|
168
173
|
// and docs/, the guidance agents read before working on the store.
|
|
169
174
|
{ label: "skills", from: ["skills", "commerce"], to: [".agents", "skills", "commerce"] },
|
|
@@ -186,10 +191,11 @@
|
|
|
186
191
|
" Base44 template — check package.json and npm i only what is truly missing;\n" +
|
|
187
192
|
" do not re-install packages already listed as dependencies\n" +
|
|
188
193
|
' 2. Mount the admin router: <Route path="/store-admin/*" element={<AdminApp />} />\n' +
|
|
189
|
-
" and implement the payment return page /order-received
|
|
194
|
+
" and implement the payment return page /order-received — one hook,\n" +
|
|
195
|
+
" useOrderReturn from @/commerce/storefront (post-installation.md)\n" +
|
|
190
196
|
" 3. Grant your user the admin role, then seed the store's data — one commerce/seed-store\n" +
|
|
191
197
|
" call (store_name required) takes the whole catalog via products, or the demo data\n" +
|
|
192
|
-
" via with_sample_data, or defaults only (post-installation.md §
|
|
198
|
+
" via with_sample_data, or defaults only (post-installation.md §3)\n" +
|
|
193
199
|
" 4. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
|
|
194
200
|
);
|
|
195
201
|
})();
|