@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 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) §2.2. 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).
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/ React admin UI (copy into your app's src/commerce/)
33
- └── utils/ storefront helpers — variant selection, shipping promos
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/` and `src/commerce/utils/` → `src/commerce/utils/`.
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) §2).
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 **helper logic**: [`src/commerce/utils/`](./src/commerce/utils/) framework-free variant-selection functions (map a Size/Color selection to a `ProductVariation` and back, per-option availability, variant price ranges) — 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.
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` §2.2). 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.
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. See seed-catalog.ts for the pipeline.
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
- for (const group of SETTINGS_DEFAULTS) {
134
- if (existingGroups.has(group.group_id)) continue;
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
- await sr.entities["commerce.StoreSettings"].create({ ...group, values });
141
- seeded.settings_groups++;
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
- for (const gw of GATEWAY_DEFAULTS) {
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) continue;
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
- for (const row of rows) {
205
- const want = methodSlugs.includes(String(row.slug));
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.enabled : paymentMethods.disabled).push(String(row.slug));
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 errors: Array<{ entity: string; error: string }> = [];
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
- errors.push({ entity: spec.entity, error: String((e as Error)?.message ?? e) });
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 errors;
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 a sequential create
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
- * On mid-write failure every record this call created is deleted (newest
516
- * first) and the error surfaces as `errorCode`; taxonomy that pre-existed is
517
- * never touched by the rollback.
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: Array<{ p: NormalizedProduct; skip?: Record<string, any> }> = [];
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
- for (const v of p.variations) {
560
- if (v.fields.sku) await assertUniqueSku(sr, String(v.fields.sku), {});
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
- plan.push({ p, skip });
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
- for (const def of spec.categories) {
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
- let allRibbons: any[] | null = null;
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 resolveRibbon = async (ribbonName: string) => {
582
- const key = ribbonName.toLowerCase();
583
- if (ribbonByLower[key]) return ribbonByLower[key];
584
- allRibbons ??= await scanAll(sr.entities["commerce.ProductRibbon"], null, "name");
585
- let ribbon = allRibbons!.find((t: any) => String(t.name ?? "").toLowerCase() === key);
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
- ribbonByLower[key] = ribbon;
592
- return ribbon;
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
- let allAttributes: any[] | null = null;
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
- for (const def of spec.attributes) {
599
- allAttributes ??= await scanAll(sr.entities["commerce.ProductAttribute"], null, "order");
600
- let attr = allAttributes!.find((a: any) => a.code === def.code);
601
- if (attr) counts.attributes.reused++;
602
- else {
603
- const maxOrder = allAttributes!.reduce((m: number, a: any) => Math.max(m, Number(a.order ?? 0)), -1);
604
- attr = await track("commerce.ProductAttribute", { name: def.name, code: def.code, order: maxOrder + 1 });
605
- allAttributes!.push(attr);
606
- counts.attributes.created++;
607
- }
608
- attributeByCode[def.code] = attr;
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
- termsByAttrId[attr.id] = terms;
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
- // ── products + variations ──────────────────────────────────────────────
632
- const results: Array<Record<string, any>> = [];
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
- for (const { p, skip } of plan) {
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
- for (const ribbonName of p.ribbonNames) record.ribbon_ids = [...record.ribbon_ids, (await resolveRibbon(ribbonName)).id];
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: any[] = [];
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
- seededVariations.push(await track("commerce.ProductVariation", rec));
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
- results.push({ name: product.name, id: product.id, slug: product.slug, sku: product.sku ?? "", variation_count: seededVariations.length });
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
- for (const [key, delta] of countDeltas) {
788
+ await pMap([...countDeltas], SEED_CONCURRENCY, ([key, delta]) => {
717
789
  const [entity, id] = key.split("|");
718
- await bumpCount(sr, entity, id, delta);
719
- }
790
+ return bumpCount(sr, entity, id, delta);
791
+ });
720
792
 
721
- // ── coupons + tax rates (skip-if-exists) ───────────────────────────────
793
+ // ── coupons + locations (skip-if-exists; codes/names are payload-unique)
722
794
  const couponCounts = { created: 0, skipped: 0 };
723
- for (const c of spec.coupons) {
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
- continue;
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
- for (const loc of spec.locations) {
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
- continue;
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
- // best-effort rollback, newest first
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` §2.2 to paste over this
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` §2.2, and the
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.15",
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",
@@ -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 (post-installation.md)\n" +
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 §2)\n" +
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
  })();