@base44/app-plugin-commerce 0.1.3 → 0.1.4

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.
Files changed (83) hide show
  1. package/README.md +8 -8
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +107 -35
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +47 -35
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  20. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  21. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  22. package/base44/shared/commerce/coupons.ts +4 -7
  23. package/base44/shared/commerce/email-templates.ts +15 -13
  24. package/base44/shared/commerce/emails.ts +4 -3
  25. package/base44/shared/commerce/payments.ts +8 -20
  26. package/base44/shared/commerce/products.ts +24 -0
  27. package/base44/shared/commerce/settings.ts +2 -3
  28. package/base44/shared/commerce/stock.ts +2 -4
  29. package/base44/shared/commerce/totals.ts +37 -42
  30. package/package.json +1 -1
  31. package/scripts/install.js +21 -4
  32. package/skills/commerce/SKILL.md +6 -5
  33. package/skills/commerce/docs/api-admin.md +16 -14
  34. package/skills/commerce/docs/api-storefront.md +39 -35
  35. package/skills/commerce/installation-guidelines.md +4 -3
  36. package/skills/commerce/post-installation.md +22 -25
  37. package/skills/commerce/references/admin-product-form.md +56 -0
  38. package/skills/commerce/references/emails.md +4 -3
  39. package/skills/commerce/references/guest-access-security.md +2 -2
  40. package/skills/commerce/references/limits-and-performance.md +1 -1
  41. package/skills/commerce/references/online-payments.md +8 -8
  42. package/skills/commerce/references/product-render.md +25 -23
  43. package/skills/commerce/references/storefront-product-page.md +9 -9
  44. package/skills/commerce/references/webhooks.md +3 -1
  45. package/src/commerce/admin/README.md +4 -4
  46. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  47. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  48. package/src/commerce/admin/index.jsx +3 -3
  49. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  50. package/src/commerce/admin/lib/constants.js +0 -7
  51. package/src/commerce/admin/lib/paths.js +6 -6
  52. package/src/commerce/admin/lib/product-utils.js +18 -15
  53. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  54. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  55. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  56. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  57. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  58. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  59. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  60. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  61. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  62. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  63. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  64. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  65. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  66. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  67. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  68. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  69. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  70. package/src/commerce/admin/routes.jsx +10 -19
  71. package/src/commerce/utils/index.js +2 -2
  72. package/src/commerce/utils/shipping-promos.js +9 -6
  73. package/src/commerce/utils/variants.js +21 -21
  74. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  75. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  76. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  77. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  78. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  79. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  80. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  81. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  82. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  83. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -10,6 +10,7 @@ import { createClientFromRequest } from "npm:@base44/sdk";
10
10
  import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
11
11
  import { getSettings } from "../../../shared/commerce/settings.ts";
12
12
  import { deriveStockStatus } from "../../../shared/commerce/stock.ts";
13
+ import { isVariable } from "../../../shared/commerce/products.ts";
13
14
  import { sendStockEmail } from "../../../shared/commerce/emails.ts";
14
15
  import { dispatch } from "../../../shared/commerce/webhooks.ts";
15
16
  import { pageSlice, scanAll, textMatch } from "../../../shared/commerce/scan.ts";
@@ -155,7 +156,7 @@ async function save(sr: any, payload: any): Promise<any> {
155
156
  rec.slug = await ensureUniqueSlug(sr, slugify(rec.slug), rec.id);
156
157
  await assertUniqueSku(sr, rec.sku, { productId: rec.id });
157
158
  derivePricing(rec);
158
- deriveStock(rec, outThreshold, !!rec.manage_stock && rec.type !== "variable");
159
+ deriveStock(rec, outThreshold, !!rec.manage_stock);
159
160
 
160
161
  const { id: _id, created_date: _cd, updated_date: _ud, created_by: _cb, ...fields } = rec;
161
162
  let saved: any;
@@ -177,10 +178,10 @@ async function save(sr: any, payload: any): Promise<any> {
177
178
  let savedVariations: any[] | undefined;
178
179
  if (Array.isArray(variations)) {
179
180
  savedVariations = await diffVariations(sr, saved, variations, outThreshold);
180
- await rollUpParentStock(sr, saved, savedVariations);
181
- } else if (saved.type === "variable") {
181
+ await rollUpParent(sr, saved, savedVariations);
182
+ } else if (isVariable(saved)) {
182
183
  const existing = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: saved.id });
183
- await rollUpParentStock(sr, saved, existing);
184
+ await rollUpParent(sr, saved, existing);
184
185
  }
185
186
 
186
187
  await dispatch(sr, prev ? "product.updated" : "product.created", saved);
@@ -213,17 +214,42 @@ async function diffVariations(sr: any, parent: any, incoming: any[], outThreshol
213
214
  return out;
214
215
  }
215
216
 
216
- /** Variable parent shows outofstock only when every purchasable variation is out. */
217
- async function rollUpParentStock(sr: any, parent: any, variations: any[]): Promise<void> {
218
- if (parent.type !== "variable" || parent.manage_stock) return;
217
+ /**
218
+ * Roll a variant parent's stock and price up from its variations.
219
+ *
220
+ * Price matters beyond display: `list-products` sorts and filters on
221
+ * `product.price`, so a parent left empty sorts as free and drops out of every
222
+ * price filter. Deriving it removes a number nobody could keep in sync by hand —
223
+ * the parent is simply the cheapest thing a customer can actually buy.
224
+ */
225
+ async function rollUpParent(sr: any, parent: any, variations: any[]): Promise<void> {
226
+ if (!isVariable(parent)) return;
219
227
  const live = variations.filter((v) => (v.status ?? "publish") === "publish");
220
- const status = live.length && live.every((v) => v.stock_status === "outofstock")
221
- ? "outofstock"
222
- : "instock";
223
- if (status !== parent.stock_status) {
224
- await sr.entities["commerce.Product"].update(parent.id, { stock_status: status });
225
- parent.stock_status = status;
228
+ const patch: Record<string, any> = {};
229
+
230
+ // manage_stock on the parent means the merchant tracks one pooled quantity,
231
+ // and with it off an explicit stock_status is their own choice to keep.
232
+ if (!parent.manage_stock) {
233
+ const status = live.length && live.every((v) => v.stock_status === "outofstock")
234
+ ? "outofstock"
235
+ : "instock";
236
+ if (status !== parent.stock_status) patch.stock_status = status;
226
237
  }
238
+
239
+ const cheapest = (key: string): number | null => {
240
+ const values = live.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
241
+ return values.length ? round2(Math.min(...values)) : null;
242
+ };
243
+ const regular = cheapest("regular_price");
244
+ const price = cheapest("price") ?? regular;
245
+ const onSale = live.some((v) => !!v.on_sale);
246
+ if (regular !== (parent.regular_price ?? null)) patch.regular_price = regular;
247
+ if (price !== (parent.price ?? null)) patch.price = price;
248
+ if (onSale !== !!parent.on_sale) patch.on_sale = onSale;
249
+
250
+ if (!Object.keys(patch).length) return;
251
+ await sr.entities["commerce.Product"].update(parent.id, patch);
252
+ Object.assign(parent, patch);
227
253
  }
228
254
 
229
255
  // ── delete / batch / duplicate ───────────────────────────────────────────────
@@ -355,10 +381,9 @@ async function categoryWithDescendants(sr: any, rootId: string): Promise<Set<str
355
381
  }
356
382
 
357
383
  async function search(sr: any, payload: any): Promise<any> {
358
- const { q, category_id, type, stock_status, status, sort = "-created_date", limit = 20, skip = 0 } = payload;
384
+ const { q, category_id, stock_status, status, sort = "-created_date", limit = 20, skip = 0 } = payload;
359
385
 
360
386
  const query: Record<string, any> = {};
361
- if (type) query.type = type;
362
387
  if (status) query.status = status;
363
388
  if (stock_status) query.stock_status = stock_status;
364
389
 
@@ -393,10 +418,10 @@ function taxonomyEntity(taxonomy: string): string {
393
418
  }
394
419
 
395
420
  /**
396
- * Upsert a category, tag, attribute or attribute term. Exists so an agent (or
421
+ * Upsert a category, tag, attribute or attribute value. Exists so an agent (or
397
422
  * any API caller) can create these — referencing one from a product is useless
398
423
  * while the record itself can only be made in the admin UI. An attribute plus
399
- * its terms is what a variable product's `attributes[].options` draws on.
424
+ * its values is what a product's `attributes[].options` draws on.
400
425
  */
401
426
  async function saveTerm(sr: any, payload: any): Promise<any> {
402
427
  const entity = taxonomyEntity(payload.taxonomy);
@@ -407,30 +432,29 @@ async function saveTerm(sr: any, payload: any): Promise<any> {
407
432
  const fields: Record<string, any> = { name };
408
433
 
409
434
  if (entity === "commerce.ProductAttributeTerm") {
410
- // A term without its attribute is unreachable, and its slug is only required
411
- // to be unique inside that attribute.
435
+ // A value without its attribute is unreachable.
412
436
  const attributeId = String(term.attribute_id ?? "").trim();
413
- if (!attributeId) throw new HttpError(400, "term.attribute_id is required for an attribute term", "invalid_payload");
437
+ if (!attributeId) throw new HttpError(400, "term.attribute_id is required for an attribute value", "invalid_payload");
414
438
  if (!(await sr.entities["commerce.ProductAttribute"].get(attributeId))) {
415
439
  throw new HttpError(404, `Attribute ${attributeId} not found`, "not_found");
416
440
  }
417
441
  fields.attribute_id = attributeId;
418
- fields.description = term.description ?? "";
419
- fields.menu_order = Number(term.menu_order ?? 0) || 0;
420
- fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity, { attribute_id: attributeId });
442
+ fields.order = Number(term.order ?? 0) || 0;
421
443
  } else if (entity === "commerce.ProductAttribute") {
422
- fields.type = term.type ?? "select";
423
- fields.order_by = term.order_by ?? "menu_order";
424
- fields.has_archives = !!term.has_archives;
425
- fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity);
426
- } else {
444
+ fields.code = await ensureUniqueCode(sr, slugify(term.code || name), term.id);
445
+ fields.order = Number(term.order ?? 0) || 0;
446
+ } else if (entity === "commerce.ProductCategory") {
427
447
  fields.description = term.description ?? "";
428
448
  fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity);
429
- if (entity === "commerce.ProductCategory") {
430
- fields.parent_id = term.parent_id ?? "";
431
- if (term.image !== undefined) fields.image = term.image;
432
- if (term.menu_order !== undefined) fields.menu_order = Number(term.menu_order) || 0;
433
- }
449
+ fields.parent_id = term.parent_id ?? "";
450
+ if (term.image !== undefined) fields.image = term.image;
451
+ if (term.menu_order !== undefined) fields.menu_order = Number(term.menu_order) || 0;
452
+ } else if (!term.id) {
453
+ // Tags are created by typing a name on the product form, so "create" has to
454
+ // mean get-or-create: a second Best Seller would split the tag in two.
455
+ const existing = (await scanAll(sr.entities[entity], null, "name"))
456
+ .find((t: any) => String(t.name ?? "").toLowerCase() === name.toLowerCase());
457
+ if (existing) return existing;
434
458
  }
435
459
 
436
460
  if (term.id) {
@@ -439,12 +463,58 @@ async function saveTerm(sr: any, payload: any): Promise<any> {
439
463
  // parent_id === own id would make the tree unwalkable (categoryWithDescendants loops on it).
440
464
  if (fields.parent_id === term.id) fields.parent_id = "";
441
465
  await sr.entities[entity].update(term.id, fields);
466
+ // Products store an attribute value by *name*, so a rename that stops here
467
+ // would leave every product and variant pointing at a value that is gone.
468
+ if (entity === "commerce.ProductAttributeTerm" && existing.name !== fields.name) {
469
+ await renameAttributeValue(sr, fields.attribute_id, existing.name, fields.name);
470
+ }
442
471
  return { ...existing, ...fields, id: term.id };
443
472
  }
444
473
  const withCount = entity === "commerce.ProductAttribute" ? fields : { ...fields, count: 0 };
445
474
  return await sr.entities[entity].create(withCount);
446
475
  }
447
476
 
477
+ /** `code` is what a storefront filter URL carries, so it has to be unique. */
478
+ async function ensureUniqueCode(sr: any, code: string, selfId?: string): Promise<string> {
479
+ let candidate = code;
480
+ for (let i = 2; i < 100; i++) {
481
+ const hits = (await sr.entities["commerce.ProductAttribute"].filter({ code: candidate }, undefined, 2)) ?? [];
482
+ if (!hits.some((a: any) => a.id !== selfId)) return candidate;
483
+ candidate = `${code}-${i}`;
484
+ }
485
+ return `${code}-${crypto.randomUUID().slice(0, 6)}`;
486
+ }
487
+
488
+ /** Rewrite a renamed value across every product and variant that used it. */
489
+ async function renameAttributeValue(sr: any, attributeId: string, from: string, to: string): Promise<void> {
490
+ const products = await scanAll(sr.entities["commerce.Product"]);
491
+ for (const product of products) {
492
+ const rows = product.attributes ?? [];
493
+ if (!rows.some((a: any) => a.attribute_id === attributeId && (a.options ?? []).includes(from))) continue;
494
+
495
+ await sr.entities["commerce.Product"].update(product.id, {
496
+ attributes: rows.map((a: any) =>
497
+ a.attribute_id === attributeId
498
+ ? { ...a, options: (a.options ?? []).map((o: string) => (o === from ? to : o)) }
499
+ : a
500
+ ),
501
+ default_attributes: (product.default_attributes ?? []).map((d: any) =>
502
+ d.attribute_id === attributeId && d.option === from ? { ...d, option: to } : d
503
+ ),
504
+ });
505
+
506
+ const variations = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: product.id });
507
+ for (const v of variations) {
508
+ if (!(v.attributes ?? []).some((a: any) => a.attribute_id === attributeId && a.option === from)) continue;
509
+ await sr.entities["commerce.ProductVariation"].update(v.id, {
510
+ attributes: v.attributes.map((a: any) =>
511
+ a.attribute_id === attributeId && a.option === from ? { ...a, option: to } : a
512
+ ),
513
+ });
514
+ }
515
+ }
516
+ }
517
+
448
518
  /**
449
519
  * Delete a term. For a category or tag, products keep the id in
450
520
  * category_ids/tag_ids — same as the admin UI, and the storefront skips ids that
@@ -494,13 +564,15 @@ async function deleteTerm(sr: any, payload: any): Promise<any> {
494
564
  /** Terms for the taxonomy, so a caller can reuse an existing one before creating a duplicate. */
495
565
  async function listTerms(sr: any, payload: any): Promise<any> {
496
566
  const entity = taxonomyEntity(payload.taxonomy);
497
- const sort = entity === "commerce.ProductCategory" || entity === "commerce.ProductAttributeTerm"
567
+ const sort = entity === "commerce.ProductCategory"
498
568
  ? "menu_order"
569
+ : entity === "commerce.ProductAttributeTerm" || entity === "commerce.ProductAttribute"
570
+ ? "order"
499
571
  : "name";
500
572
  const query = entity === "commerce.ProductAttributeTerm" && payload.attribute_id
501
573
  ? { attribute_id: String(payload.attribute_id) }
502
574
  : null;
503
575
  let rows = await scanAll(sr.entities[entity], query, sort);
504
- if (payload.q) rows = rows.filter((t: any) => textMatch(t.name, payload.q) || textMatch(t.slug, payload.q));
576
+ if (payload.q) rows = rows.filter((t: any) => textMatch(t.name, payload.q) || textMatch(t.code, payload.q));
505
577
  return pageSlice(rows, Number(payload.limit ?? 100), Number(payload.skip ?? 0));
506
578
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * commerce/admin-refunds — create/delete order refunds with optional restock and
3
- * (placeholder) gateway refunds.
3
+ * provider refunds through shared/commerce/payments.
4
4
  *
5
5
  * Actions: create | delete
6
6
  */
@@ -30,7 +30,7 @@ Deno.serve(async (req) => {
30
30
  case "top-sellers": return ok(await topSellers(sr, payload));
31
31
  case "stock": return ok(await stockReport(sr));
32
32
  case "orders-totals": return ok(await ordersTotals(sr));
33
- case "products-totals": return ok(await groupCount(sr.entities["commerce.Product"], "type"));
33
+ case "products-totals": return ok(await groupCount(sr.entities["commerce.Product"], "status"));
34
34
  case "customers-totals": return ok(await customersTotals(sr));
35
35
  case "coupons-totals": return ok(await groupCount(sr.entities["commerce.Coupon"], "discount_type"));
36
36
  case "reviews-totals": return ok(await groupCount(sr.entities["commerce.ProductReview"], "status"));
@@ -116,9 +116,10 @@ async function status(sr: any): Promise<any> {
116
116
  settings_groups: groups,
117
117
  counts,
118
118
  checks: {
119
- has_payment_gateways: (counts.PaymentGateway as number) > 0,
120
- has_default_zone: (counts.ShippingZone as number) > 0,
121
- taxes_enabled: settings.general?.enable_taxes ?? null,
119
+ // counts is keyed by the namespaced entity name — `counts.PaymentGateway`
120
+ // is undefined, which silently reported both checks as false.
121
+ has_payment_gateways: (counts["commerce.PaymentGateway"] as number) > 0,
122
+ has_default_zone: (counts["commerce.ShippingZone"] as number) > 0,
122
123
  },
123
124
  };
124
125
  }
@@ -120,7 +120,6 @@ Deno.serve(async (req: Request) => {
120
120
  successUrl: payload.success_url,
121
121
  cancelUrl: payload.cancel_url,
122
122
  returnUrl: payload.return_url,
123
- storeUrl: settings.general?.store_url,
124
123
  returnPath: settings.general?.order_received_path,
125
124
  });
126
125
  const link = await startOnlinePayment(sr, order, {
@@ -164,7 +163,6 @@ Deno.serve(async (req: Request) => {
164
163
  successUrl: payload.success_url,
165
164
  cancelUrl: payload.cancel_url,
166
165
  returnUrl: payload.return_url,
167
- storeUrl: settings.general?.store_url,
168
166
  returnPath: settings.general?.order_received_path,
169
167
  });
170
168
  const link = await startOnlinePayment(sr, result.order, {
@@ -24,17 +24,8 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
24
24
  {
25
25
  group_id: "general",
26
26
  values: {
27
- store_name: "", // seed-store payload supplies it (the app/store name)
28
- // Public URL of the shopfront (e.g. https://shop.example.com). Used to
29
- // build the page a customer returns to after paying; when empty the
30
- // origin of the calling app is used instead.
31
- store_url: "",
32
27
  // Storefront route customers return to after paying; a mismatch is a 404.
33
28
  order_received_path: "/order-received",
34
- address: { address_1: "", address_2: "", city: "", state: "", postcode: "", country: "US" },
35
- enable_taxes: true,
36
- enable_coupons: true,
37
- calc_discounts_sequentially: false,
38
29
  currency: "USD",
39
30
  currency_position: "left", // left | right | left_space | right_space
40
31
  thousand_sep: ",",
@@ -70,7 +61,7 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
70
61
  group_id: "tax",
71
62
  values: {
72
63
  prices_include_tax: false,
73
- tax_based_on: "shipping", // shipping | billing | base
64
+ tax_based_on: "shipping", // shipping | billing
74
65
  shipping_tax_class: "inherit", // inherit | standard | <class slug>
75
66
  display_prices_shop: "excl", // incl | excl
76
67
  display_prices_cart: "excl",
@@ -85,7 +76,9 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
85
76
  {
86
77
  group_id: "emails",
87
78
  values: {
88
- from_name: "", // blank: emails.ts inherits general.store_name
79
+ // Subject line and sender name both. seed-store's payload supplies it;
80
+ // blank means SendEmail gets no from_name and Base44 sends as the app's name.
81
+ store_name: "",
89
82
  admin_recipients: [],
90
83
  ...EMAIL_TYPE_DEFAULTS,
91
84
  },
@@ -94,35 +87,16 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
94
87
 
95
88
  export const GATEWAY_DEFAULTS = [
96
89
  {
97
- slug: "bacs",
98
- title: "Direct bank transfer",
99
- description: "Make your payment directly into our bank account. Please use your Order ID as the payment reference.",
90
+ slug: "offline",
91
+ title: "Offline payment",
92
+ description: "Pay outside the store — bank transfer on the details below, or cash on delivery. Please quote your order number.",
100
93
  enabled: true,
101
94
  order: 0,
102
- method_title: "Direct bank transfer",
103
- method_description: "Take payments in person via BACS. Orders are set on-hold until payment clears.",
95
+ method_title: "Offline payment",
96
+ method_description:
97
+ "Any payment settled outside the store: bank transfer, cash on delivery, or whatever you arrange with the customer. Orders are set on-hold until you confirm the money arrived.",
104
98
  settings: { account_details: [] as any[] },
105
99
  },
106
- {
107
- slug: "cheque",
108
- title: "Check payments",
109
- description: "Please send a check to our store address.",
110
- enabled: false,
111
- order: 1,
112
- method_title: "Check payments",
113
- method_description: "Take payments by check. Orders are set on-hold until payment clears.",
114
- settings: {},
115
- },
116
- {
117
- slug: "cod",
118
- title: "Cash on delivery",
119
- description: "Pay with cash upon delivery.",
120
- enabled: true,
121
- order: 2,
122
- method_title: "Cash on delivery",
123
- method_description: "Have your customers pay with cash (or by other means) upon delivery.",
124
- settings: { enable_for_methods: [] as string[], enable_for_virtual: false },
125
- },
126
100
  {
127
101
  slug: "stripe",
128
102
  title: "Credit card",
@@ -132,10 +106,10 @@ export const GATEWAY_DEFAULTS = [
132
106
  // storefront gateway list and the admin both check that live — so enabling it
133
107
  // here cannot strand a shopper on an unusable option.
134
108
  enabled: true,
135
- order: 3,
136
- method_title: "Online card payments",
109
+ order: 1,
110
+ method_title: "Online card payments (Stripe by default)",
137
111
  method_description:
138
- "Card payments through the connected payment provider (Stripe out of the box). Connect the provider's connector to start taking payments; customers only see this option once it is connected.",
112
+ "Card payments through the connected payment provider (Stripe out of the box). Set the provider up in the app's integrations to start taking payments; customers only see this option once it is connected.",
139
113
  settings: { connector: "stripe" },
140
114
  },
141
115
  ];
@@ -4,8 +4,8 @@
4
4
  * Flow: (1) canary schema validation (probe create+delete per entity; abort
5
5
  * with 422 schema_incompatible and write nothing on any failure), (2) seed
6
6
  * required defaults (settings groups, gateways, tax classes, fallback zone),
7
- * (3) optional sample catalog (~12 products across every product type, with
8
- * images and descriptions, plus three variable products with variants) — only
7
+ * (3) optional sample catalog (10 products with images and
8
+ * descriptions, three of which carry attributes and ship with variants) — only
9
9
  * when with_sample_data=true and the store has zero products, with best-effort
10
10
  * rollback on mid-failure.
11
11
  *
@@ -62,12 +62,12 @@ Deno.serve(async (req) => {
62
62
  const existingGroups = new Set(existingSettings.map((s: any) => s.group_id));
63
63
  // A nameless store renders subjects like "[]: New order #1002", so refuse to
64
64
  // create one rather than seeding a blank the caller never sees.
65
- if (!existingGroups.has("general") && !storeName) {
65
+ if (!existingGroups.has("emails") && !storeName) {
66
66
  return fail(400, "store_name is required — pass the app's name as the platform shows it.", "store_name_required");
67
67
  }
68
68
  for (const group of SETTINGS_DEFAULTS) {
69
69
  if (existingGroups.has(group.group_id)) continue;
70
- const values = group.group_id === "general"
70
+ const values = group.group_id === "emails"
71
71
  ? { ...group.values, store_name: storeName }
72
72
  : group.values;
73
73
  await sr.entities["commerce.StoreSettings"].create({ ...group, values });
@@ -77,12 +77,12 @@ Deno.serve(async (req) => {
77
77
  // A passed store_name must never be silently dropped: fill a blank one on an
78
78
  // already-seeded store, but never overwrite a name the merchant chose.
79
79
  let storeNameAction = storeName ? "created" : "unchanged";
80
- if (storeName && existingGroups.has("general")) {
81
- const general = existingSettings.find((r: any) => r.group_id === "general");
82
- const current = String(general?.values?.store_name ?? "").trim();
80
+ if (storeName && existingGroups.has("emails")) {
81
+ const emails = existingSettings.find((r: any) => r.group_id === "emails");
82
+ const current = String(emails?.values?.store_name ?? "").trim();
83
83
  if (!current) {
84
- await sr.entities["commerce.StoreSettings"].update(general.id, {
85
- values: { ...(general.values ?? {}), store_name: storeName },
84
+ await sr.entities["commerce.StoreSettings"].update(emails.id, {
85
+ values: { ...(emails.values ?? {}), store_name: storeName },
86
86
  });
87
87
  storeNameAction = "filled";
88
88
  } else {
@@ -125,7 +125,7 @@ Deno.serve(async (req) => {
125
125
  }
126
126
 
127
127
  const currentName = String(
128
- (existingSettings.find((r: any) => r.group_id === "general")?.values?.store_name ?? storeName) || storeName,
128
+ (existingSettings.find((r: any) => r.group_id === "emails")?.values?.store_name ?? storeName) || storeName,
129
129
  );
130
130
  return ok({
131
131
  seeded,
@@ -152,9 +152,9 @@ function canarySpecs(withSample: boolean): Array<{ entity: string; record: Recor
152
152
  ];
153
153
  const sample = [
154
154
  { entity: "commerce.ProductCategory", record: { name: "__canary", slug: "__canary", count: 0 } },
155
- { entity: "commerce.ProductAttribute", record: { name: "__canary", slug: "__canary", type: "select" } },
156
- { entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary", slug: "__canary" } },
157
- { entity: "commerce.Product", record: { name: "__canary", type: "simple", status: "draft", regular_price: 1, price: 1 } },
155
+ { entity: "commerce.ProductAttribute", record: { name: "__canary", code: "__canary", order: 0 } },
156
+ { entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary", order: 0 } },
157
+ { entity: "commerce.Product", record: { name: "__canary", status: "draft", regular_price: 1, price: 1 } },
158
158
  { entity: "commerce.ProductVariation", record: { product_id: "__canary", attributes: [], status: "draft" } },
159
159
  { entity: "commerce.Coupon", record: { code: "__canary", discount_type: "percent", amount: 1, usage_count: 0, used_by: [] } },
160
160
  { entity: "commerce.TaxRate", record: { country: "ZZ", rate: 1, name: "__canary", priority: 1, compound: false, shipping: true, tax_class: "standard" } },
@@ -212,23 +212,22 @@ async function seedSampleData(sr: any): Promise<Record<string, number>> {
212
212
  categoryBySlug[cat.slug] = await track("commerce.ProductCategory", cat);
213
213
  }
214
214
 
215
- // attributes + terms
216
- const attributeBySlug: Record<string, any> = {};
215
+ // attributes + values
216
+ const attributeByCode: Record<string, any> = {};
217
217
  for (const attr of SAMPLE_ATTRIBUTES) {
218
218
  const rec = await track("commerce.ProductAttribute", attr);
219
- attributeBySlug[attr.slug] = rec;
220
- for (const term of SAMPLE_ATTRIBUTE_TERMS[attr.slug] ?? []) {
221
- await track("commerce.ProductAttributeTerm", { ...term, attribute_id: rec.id, count: 0 });
219
+ attributeByCode[attr.code] = rec;
220
+ for (const value of SAMPLE_ATTRIBUTE_TERMS[attr.code] ?? []) {
221
+ await track("commerce.ProductAttributeTerm", { ...value, attribute_id: rec.id, count: 0 });
222
222
  }
223
223
  }
224
224
 
225
- // products (two passes: grouped references resolved after simple products exist)
226
225
  const productByKey: Record<string, any> = {};
227
226
  let variationCount = 0;
228
- const specs = [...SAMPLE_PRODUCTS].sort((a, b) => (a.type === "grouped" ? 1 : 0) - (b.type === "grouped" ? 1 : 0));
227
+ const specs = SAMPLE_PRODUCTS;
229
228
 
230
229
  for (const spec of specs) {
231
- const { key, categories, attributes, default_attributes, variations, grouped_keys, options: _o, ...fields } = spec;
230
+ const { key, categories, attributes, default_attributes, variations, options: _o, ...fields } = spec;
232
231
  const record: any = deriveSeedProduct({
233
232
  ...fields,
234
233
  slug: fields.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
@@ -240,34 +239,30 @@ async function seedSampleData(sr: any): Promise<Record<string, number>> {
240
239
  });
241
240
  if (attributes) {
242
241
  record.attributes = attributes.map((a: any, i: number) => ({
243
- attribute_id: attributeBySlug[a.slug]?.id ?? "",
244
- name: attributeBySlug[a.slug]?.name ?? a.slug,
242
+ attribute_id: attributeByCode[a.code]?.id ?? "",
243
+ name: attributeByCode[a.code]?.name ?? a.code,
245
244
  position: i,
246
- visible: a.visible !== false,
247
- variation: !!a.variation,
248
245
  options: a.options ?? [],
249
246
  }));
250
247
  }
251
248
  if (default_attributes) {
252
249
  record.default_attributes = default_attributes.map((d: any) => ({
253
- attribute_id: attributeBySlug[d.slug]?.id ?? "",
254
- name: attributeBySlug[d.slug]?.name ?? d.slug,
250
+ attribute_id: attributeByCode[d.code]?.id ?? "",
251
+ name: attributeByCode[d.code]?.name ?? d.code,
255
252
  option: d.option,
256
253
  }));
257
254
  }
258
- if (grouped_keys) {
259
- record.grouped_products = grouped_keys.map((k: string) => productByKey[k]?.id).filter(Boolean);
260
- }
261
255
  const product = await track("commerce.Product", record);
262
256
  productByKey[key] = product;
263
257
 
258
+ const seededVariations: any[] = [];
264
259
  for (const v of variations ?? []) {
265
- const attrs = Object.entries(v.options).map(([slug, option]) => ({
266
- attribute_id: attributeBySlug[slug]?.id ?? "",
267
- name: attributeBySlug[slug]?.name ?? slug,
260
+ const attrs = Object.entries(v.options).map(([code, option]) => ({
261
+ attribute_id: attributeByCode[code]?.id ?? "",
262
+ name: attributeByCode[code]?.name ?? code,
268
263
  option,
269
264
  }));
270
- await track("commerce.ProductVariation", deriveSeedProduct({
265
+ seededVariations.push(await track("commerce.ProductVariation", deriveSeedProduct({
271
266
  product_id: product.id,
272
267
  attributes: attrs,
273
268
  status: "publish",
@@ -278,9 +273,26 @@ async function seedSampleData(sr: any): Promise<Record<string, number>> {
278
273
  stock_quantity: v.stock_quantity,
279
274
  backorders: v.backorders ?? "no",
280
275
  ...(v.image ? { image: v.image } : {}),
281
- }));
276
+ })));
282
277
  variationCount++;
283
278
  }
279
+
280
+ // The parent's price is the cheapest variant. commerce/admin-products
281
+ // derives this on save, but the seeder writes entities directly — without
282
+ // this a seeded variant product has no price, so it sorts as free and
283
+ // drops out of every price filter.
284
+ if (seededVariations.length) {
285
+ const cheapest = (key: string) => {
286
+ const values = seededVariations.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
287
+ return values.length ? round2(Math.min(...values)) : null;
288
+ };
289
+ const regular = cheapest("regular_price");
290
+ await sr.entities["commerce.Product"].update(product.id, {
291
+ regular_price: regular,
292
+ price: cheapest("price") ?? regular,
293
+ on_sale: seededVariations.some((v) => !!v.on_sale),
294
+ });
295
+ }
284
296
  }
285
297
 
286
298
  // taxonomy counts for the seeded catalog