@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
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Optional demo catalog: 3 categories, 2 global attributes (+terms),
3
- * ~12 products covering every product type (including 3 variable products
4
- * with variants), 2 coupons, 2 tax rates. Every product ships with a
2
+ * Optional demo catalog: 3 categories, 2 shared attributes (+values),
3
+ * 10 products (3 of which carry attributes and ship with variants),
4
+ * 2 coupons, 2 tax rates. Every product ships with a
5
5
  * description, short description and a small image gallery so the storefront
6
6
  * and admin look populated out of the box.
7
7
  * Only installed when with_sample_data=true AND the store has no products.
@@ -32,29 +32,29 @@ export const SAMPLE_CATEGORIES = [
32
32
  ];
33
33
 
34
34
  export const SAMPLE_ATTRIBUTES = [
35
- { name: "Color", slug: "color", type: "select", order_by: "menu_order", has_archives: false },
36
- { name: "Size", slug: "size", type: "select", order_by: "menu_order", has_archives: false },
35
+ { name: "Color", code: "color", order: 0 },
36
+ { name: "Size", code: "size", order: 1 },
37
37
  ];
38
38
 
39
- export const SAMPLE_ATTRIBUTE_TERMS: Record<string, Array<{ name: string; slug: string; menu_order: number }>> = {
39
+ export const SAMPLE_ATTRIBUTE_TERMS: Record<string, Array<{ name: string; order: number }>> = {
40
40
  color: [
41
- { name: "Red", slug: "red", menu_order: 0 },
42
- { name: "Blue", slug: "blue", menu_order: 1 },
43
- { name: "Black", slug: "black", menu_order: 2 },
44
- { name: "Green", slug: "green", menu_order: 3 },
41
+ { name: "Red", order: 0 },
42
+ { name: "Blue", order: 1 },
43
+ { name: "Black", order: 2 },
44
+ { name: "Green", order: 3 },
45
45
  ],
46
46
  size: [
47
- { name: "S", slug: "s", menu_order: 0 },
48
- { name: "M", slug: "m", menu_order: 1 },
49
- { name: "L", slug: "l", menu_order: 2 },
50
- { name: "XL", slug: "xl", menu_order: 3 },
47
+ { name: "S", order: 0 },
48
+ { name: "M", order: 1 },
49
+ { name: "L", order: 2 },
50
+ { name: "XL", order: 3 },
51
51
  ],
52
52
  };
53
53
 
54
54
  /**
55
55
  * Per-colour variation photos, keyed by product: a colour swatch is only useful
56
56
  * if it shows the right *garment* in that colour, so these can't be shared
57
- * across product types. Products with no colour-accurate photo available (the
57
+ * between products. Products with no colour-accurate photo available (the
58
58
  * tote) are deliberately absent — their variations fall back to the product
59
59
  * gallery, which beats showing an unrelated item.
60
60
  */
@@ -79,15 +79,14 @@ const colorImage = (name: string, color: string) => {
79
79
  };
80
80
 
81
81
  /**
82
- * Product specs — category/attribute references are by slug and resolved to
83
- * ids at seed time. `variations` uses attribute slug→option pairs and may carry
84
- * a per-variation `image`.
82
+ * Product specs — categories are referenced by slug and attributes by `code`,
83
+ * both resolved to ids at seed time. `variations` uses attribute code→option
84
+ * pairs and may carry a per-variation `image`.
85
85
  */
86
86
  export const SAMPLE_PRODUCTS: any[] = [
87
87
  {
88
88
  key: "tshirt",
89
89
  name: "Classic T-Shirt",
90
- type: "simple",
91
90
  status: "publish",
92
91
  categories: ["clothing"],
93
92
  regular_price: 19.99,
@@ -105,7 +104,6 @@ export const SAMPLE_PRODUCTS: any[] = [
105
104
  {
106
105
  key: "hoodie",
107
106
  name: "Zip Hoodie",
108
- type: "simple",
109
107
  status: "publish",
110
108
  categories: ["clothing"],
111
109
  regular_price: 44.99,
@@ -123,7 +121,6 @@ export const SAMPLE_PRODUCTS: any[] = [
123
121
  {
124
122
  key: "cap",
125
123
  name: "Logo Cap",
126
- type: "simple",
127
124
  status: "publish",
128
125
  categories: ["accessories"],
129
126
  regular_price: 14.99,
@@ -140,7 +137,6 @@ export const SAMPLE_PRODUCTS: any[] = [
140
137
  {
141
138
  key: "stickers",
142
139
  name: "Sticker Pack",
143
- type: "simple",
144
140
  status: "publish",
145
141
  categories: ["accessories"],
146
142
  regular_price: 4.99,
@@ -156,7 +152,6 @@ export const SAMPLE_PRODUCTS: any[] = [
156
152
  {
157
153
  key: "album",
158
154
  name: "Demo Album (Digital Download)",
159
- type: "simple",
160
155
  status: "publish",
161
156
  categories: ["downloads"],
162
157
  regular_price: 9.99,
@@ -175,7 +170,6 @@ export const SAMPLE_PRODUCTS: any[] = [
175
170
  {
176
171
  key: "poster",
177
172
  name: "Art Print Poster",
178
- type: "simple",
179
173
  status: "publish",
180
174
  categories: ["accessories"],
181
175
  regular_price: 12.99,
@@ -191,7 +185,6 @@ export const SAMPLE_PRODUCTS: any[] = [
191
185
  {
192
186
  key: "vneck",
193
187
  name: "V-Neck T-Shirt",
194
- type: "variable",
195
188
  status: "publish",
196
189
  categories: ["clothing"],
197
190
  sku: "DEMO-VNECK",
@@ -201,10 +194,10 @@ export const SAMPLE_PRODUCTS: any[] = [
201
194
  "<p>A fitted v-neck tee in a lightweight jersey knit, available across multiple colors and sizes.</p>" +
202
195
  "<ul><li>Slim, tailored fit</li><li>Lightweight 150 gsm jersey</li><li>Six color/size combinations</li></ul>",
203
196
  attributes: [
204
- { slug: "color", visible: true, variation: true, options: ["Red", "Blue"] },
205
- { slug: "size", visible: true, variation: true, options: ["S", "M", "L"] },
197
+ { code: "color", options: ["Red", "Blue"] },
198
+ { code: "size", options: ["S", "M", "L"] },
206
199
  ],
207
- default_attributes: [{ slug: "color", option: "Red" }, { slug: "size", option: "M" }],
200
+ default_attributes: [{ code: "color", option: "Red" }, { code: "size", option: "M" }],
208
201
  variations: [
209
202
  { options: { color: "Red", size: "S" }, regular_price: 21.99, stock_quantity: 10, image: colorImage("V-Neck T-Shirt", "Red") },
210
203
  { options: { color: "Red", size: "M" }, regular_price: 21.99, stock_quantity: 10, image: colorImage("V-Neck T-Shirt", "Red") },
@@ -217,7 +210,6 @@ export const SAMPLE_PRODUCTS: any[] = [
217
210
  {
218
211
  key: "pullover",
219
212
  name: "Colorblock Pullover Hoodie",
220
- type: "variable",
221
213
  status: "publish",
222
214
  categories: ["clothing"],
223
215
  sku: "DEMO-PULLOVER",
@@ -227,10 +219,10 @@ export const SAMPLE_PRODUCTS: any[] = [
227
219
  "<p>A heavyweight brushed-back pullover hoodie with a double-layer hood and ribbed cuffs. Some combinations are on sale.</p>" +
228
220
  "<ul><li>380 gsm brushed-back fleece</li><li>Double-layer drawcord hood</li><li>Nine color/size combinations</li></ul>",
229
221
  attributes: [
230
- { slug: "color", visible: true, variation: true, options: ["Black", "Blue", "Green"] },
231
- { slug: "size", visible: true, variation: true, options: ["M", "L", "XL"] },
222
+ { code: "color", options: ["Black", "Blue", "Green"] },
223
+ { code: "size", options: ["M", "L", "XL"] },
232
224
  ],
233
- default_attributes: [{ slug: "color", option: "Black" }, { slug: "size", option: "L" }],
225
+ default_attributes: [{ code: "color", option: "Black" }, { code: "size", option: "L" }],
234
226
  variations: [
235
227
  { options: { color: "Black", size: "M" }, regular_price: 49.99, stock_quantity: 12, image: colorImage("Colorblock Pullover Hoodie", "Black") },
236
228
  { options: { color: "Black", size: "L" }, regular_price: 49.99, stock_quantity: 12, image: colorImage("Colorblock Pullover Hoodie", "Black") },
@@ -246,57 +238,27 @@ export const SAMPLE_PRODUCTS: any[] = [
246
238
  {
247
239
  key: "tote",
248
240
  name: "Canvas Tote Bag",
249
- type: "variable",
250
241
  status: "publish",
251
242
  categories: ["accessories"],
252
243
  sku: "DEMO-TOTE",
253
244
  images: gallery("Canvas Tote Bag", ["photo-1544816155-12df9643f363"]),
254
- short_description: "Sturdy canvas tote — a single-attribute variable product in three colors.",
245
+ short_description: "Sturdy canvas tote — one attribute, three colors.",
255
246
  description:
256
- "<p>A roomy, sturdy cotton-canvas tote with reinforced handles. Demonstrates a variable product driven by a single attribute (color).</p>" +
247
+ "<p>A roomy, sturdy cotton-canvas tote with reinforced handles. Demonstrates variants driven by a single attribute (color).</p>" +
257
248
  "<ul><li>12 oz heavyweight cotton canvas</li><li>Reinforced 28 cm handles</li><li>Interior slip pocket</li></ul>",
258
249
  attributes: [
259
- { slug: "color", visible: true, variation: true, options: ["Black", "Blue", "Green"] },
250
+ { code: "color", options: ["Black", "Blue", "Green"] },
260
251
  ],
261
- default_attributes: [{ slug: "color", option: "Black" }],
252
+ default_attributes: [{ code: "color", option: "Black" }],
262
253
  variations: [
263
254
  { options: { color: "Black" }, regular_price: 18.99, stock_quantity: 25, image: colorImage("Canvas Tote Bag", "Black") },
264
255
  { options: { color: "Blue" }, regular_price: 18.99, stock_quantity: 20, image: colorImage("Canvas Tote Bag", "Blue") },
265
256
  { options: { color: "Green" }, regular_price: 18.99, stock_quantity: 0, backorders: "notify", image: colorImage("Canvas Tote Bag", "Green") },
266
257
  ],
267
258
  },
268
- {
269
- key: "mug",
270
- name: "Partner Mug (External)",
271
- type: "external",
272
- status: "publish",
273
- categories: ["accessories"],
274
- regular_price: 11.99,
275
- sku: "DEMO-MUG",
276
- external_url: "https://example.com/partner/mug",
277
- button_text: "Buy at partner store",
278
- images: gallery("Partner Mug", ["photo-1514228742587-6b1558fcca3d", "photo-1516390118834-21602d501886"]),
279
- short_description: "Sold on our partner's site — an external/affiliate product.",
280
- description:
281
- "<p>A 350 ml ceramic mug sold through our partner store. An example of an external/affiliate product whose buy button links off-site.</p>" +
282
- "<ul><li>350 ml ceramic body</li><li>Dishwasher &amp; microwave safe</li><li>Fulfilled by our partner</li></ul>",
283
- },
284
- {
285
- key: "bundle",
286
- name: "Starter Bundle",
287
- type: "grouped",
288
- status: "publish",
289
- categories: ["clothing", "accessories"],
290
- grouped_keys: ["tshirt", "cap", "stickers"], // resolved to grouped_products ids at seed time
291
- images: gallery("Starter Bundle", ["photo-1479064555552-3ef4979f8908"]),
292
- short_description: "Tee, cap and sticker pack together in one grouped listing.",
293
- description:
294
- "<p>A grouped product that bundles the Classic T-Shirt, Logo Cap and Sticker Pack into a single listing. Customers pick quantities for each item.</p>",
295
- },
296
259
  {
297
260
  key: "beanie",
298
261
  name: "Wool Beanie",
299
- type: "simple",
300
262
  status: "publish",
301
263
  categories: ["accessories"],
302
264
  regular_price: 16.99,
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { createClientFromRequest } from "npm:@base44/sdk";
13
13
  import { HttpError, getCallerUser } from "../../../shared/commerce/auth.ts";
14
- import { getSetting } from "../../../shared/commerce/settings.ts";
15
14
  import { checkPurchasable } from "../../../shared/commerce/stock.ts";
16
15
  import { validateCoupon } from "../../../shared/commerce/coupons.ts";
17
16
  import { uuid } from "../../../shared/commerce/sequence.ts";
@@ -177,9 +176,6 @@ Deno.serve(async (req: Request) => {
177
176
  case "apply-coupon": {
178
177
  const cart = await loadCart(sr, payload.cart_token);
179
178
  const pricingData = await loadPricingData(sr);
180
- if (!getSetting(pricingData.settings, "general", "enable_coupons", true)) {
181
- throw new HttpError(400, "Coupons are disabled for this store.", "coupons_disabled");
182
- }
183
179
  const code = String(payload.code || "").toLowerCase().trim();
184
180
  if (!code) throw new HttpError(400, "Coupon code is required.", "code_required");
185
181
  if ((cart.coupon_codes || []).includes(code)) {
@@ -250,9 +246,6 @@ async function addItem(sr: any, cart: any, payload: any): Promise<void> {
250
246
  const { product_id, variation_id, attributes } = payload;
251
247
  let quantity = Math.max(1, Math.floor(Number(payload.quantity) || 1));
252
248
  const { product, variation } = await loadProductPair(sr, product_id, variation_id);
253
- if (product.type === "variable" && !variation) {
254
- throw new HttpError(400, "Please choose product options.", "variation_required");
255
- }
256
249
  if (product.sold_individually) quantity = 1;
257
250
 
258
251
  const items = [...(cart.items || [])];
@@ -12,6 +12,7 @@ import { createClientFromRequest } from "npm:@base44/sdk";
12
12
  import { getCallerUser, HttpError, requireUser } from "../../../shared/commerce/auth.ts";
13
13
  import { getSettings, getSetting, storefrontSafeSettings } from "../../../shared/commerce/settings.ts";
14
14
  import { isOnlineGateway, onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
15
+ import { isVariable } from "../../../shared/commerce/products.ts";
15
16
  import { recalcProductRating } from "../../../shared/commerce/reviews.ts";
16
17
  import { scanAll } from "../../../shared/commerce/scan.ts";
17
18
  import { COUNTRIES } from "../../../shared/commerce/data/countries.ts";
@@ -69,8 +70,8 @@ Deno.serve(async (req: Request) => {
69
70
  * `downloads[].file_url` is the paywalled file itself — access to it is gated by
70
71
  * commerce.DownloadPermission (limits, expiry, signed URLs) via
71
72
  * commerce/storefront-account `get-download`, so it can't ride along in the
72
- * catalog. `purchase_note` is a post-purchase message, not pre-sale content.
73
- * The `downloadable` flag is kept so a shopfront can still badge the product.
73
+ * catalog. The `downloadable` flag is kept so a shopfront can still badge the
74
+ * product.
74
75
  */
75
76
  function publicProduct<T extends Record<string, any>>(product: T): T {
76
77
  if (!product || typeof product !== "object") return product;
@@ -78,7 +79,6 @@ function publicProduct<T extends Record<string, any>>(product: T): T {
78
79
  downloads: _downloads,
79
80
  download_limit: _dlLimit,
80
81
  download_expiry: _dlExpiry,
81
- purchase_note: _note,
82
82
  ...safe
83
83
  } = product as Record<string, any>;
84
84
  return safe as T;
@@ -87,7 +87,7 @@ function publicProduct<T extends Record<string, any>>(product: T): T {
87
87
  // ── list-products ────────────────────────────────────────────────────────────
88
88
 
89
89
  const SORTS: Record<string, (a: any, b: any) => number> = {
90
- "menu_order": (a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || String(a.name).localeCompare(String(b.name)),
90
+ "name": (a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")),
91
91
  "price": (a, b) => (a.price ?? 0) - (b.price ?? 0),
92
92
  "-price": (a, b) => (b.price ?? 0) - (a.price ?? 0),
93
93
  "-created_date": (a, b) => String(b.created_date ?? "").localeCompare(String(a.created_date ?? "")),
@@ -143,8 +143,8 @@ async function listProducts(sr: any, p: any): Promise<any> {
143
143
  if (p.featured != null) products = products.filter((prod: any) => !!prod.featured === !!p.featured);
144
144
  if (p.on_sale != null) products = products.filter((prod: any) => !!prod.on_sale === !!p.on_sale);
145
145
 
146
- const sortKey = String(p.sort ?? "menu_order");
147
- products.sort(SORTS[sortKey] ?? SORTS["menu_order"]);
146
+ const sortKey = String(p.sort ?? "-created_date");
147
+ products.sort(SORTS[sortKey] ?? SORTS["-created_date"]);
148
148
 
149
149
  const start = (page - 1) * perPage;
150
150
  const pageItems = await withTags(sr, products.slice(start, start + perPage).map(publicProduct));
@@ -160,13 +160,12 @@ async function listProducts(sr: any, p: any): Promise<any> {
160
160
  * Attach resolved `tags` to listing rows. A row otherwise carries only tag_ids,
161
161
  * which is why cards end up with no tags at all — the shape a card needs has to
162
162
  * be in the row it renders. One tag read per request, not per row; kept to
163
- * {id, name, slug} so a listing payload stays small (use list-tags for
164
- * descriptions and counts).
163
+ * {id, name} so a listing payload stays small (use list-tags for counts).
165
164
  */
166
165
  async function withTags(sr: any, rows: any[]): Promise<any[]> {
167
166
  if (!rows.some((r) => (r.tag_ids ?? []).length)) return rows;
168
167
  const all = await scanAll(sr.entities["commerce.ProductTag"], {}, "name", 2000);
169
- const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name, slug: t.slug }]));
168
+ const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name }]));
170
169
  return rows.map((r) => ({
171
170
  ...r,
172
171
  tags: (r.tag_ids ?? []).map((id: string) => byId.get(id)).filter(Boolean),
@@ -205,7 +204,7 @@ async function getProduct(sr: any, p: any): Promise<any> {
205
204
  }
206
205
 
207
206
  const [variations, categories, tags] = await Promise.all([
208
- product.type === "variable"
207
+ isVariable(product)
209
208
  ? sr.entities["commerce.ProductVariation"].filter({ product_id: product.id }, "menu_order", 500)
210
209
  : Promise.resolve([]),
211
210
  resolveMany(sr.entities["commerce.ProductCategory"], product.category_ids),
@@ -225,10 +224,9 @@ async function getProduct(sr: any, p: any): Promise<any> {
225
224
  (reviewsPage - 1) * reviewsPerPage,
226
225
  )) ?? [];
227
226
 
228
- const [upsells, crossSells, grouped] = await Promise.all([
227
+ const [upsells, crossSells] = await Promise.all([
229
228
  productSummaries(sr, product.upsell_ids),
230
229
  productSummaries(sr, product.cross_sell_ids),
231
- productSummaries(sr, product.grouped_products),
232
230
  ]);
233
231
 
234
232
  return {
@@ -246,7 +244,6 @@ async function getProduct(sr: any, p: any): Promise<any> {
246
244
  },
247
245
  upsells,
248
246
  cross_sells: crossSells,
249
- grouped_products: grouped,
250
247
  };
251
248
  }
252
249
 
@@ -347,8 +344,6 @@ async function listTags(sr: any, p: any): Promise<any> {
347
344
  .map((t: any) => ({
348
345
  id: t.id,
349
346
  name: t.name,
350
- slug: t.slug,
351
- description: t.description ?? "",
352
347
  count: counts.get(t.id) ?? 0,
353
348
  }))
354
349
  .filter((t: any) => p?.with_products_only === false || t.count > 0);
@@ -356,10 +351,10 @@ async function listTags(sr: any, p: any): Promise<any> {
356
351
  }
357
352
 
358
353
  async function listAttributes(sr: any): Promise<any> {
359
- const attributes = await scanAll(sr.entities["commerce.ProductAttribute"], {}, undefined, 500);
354
+ const attributes = await scanAll(sr.entities["commerce.ProductAttribute"], {}, "order", 500);
360
355
  const out: any[] = [];
361
356
  for (const attr of attributes) {
362
- const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, "menu_order", 500)) ?? [];
357
+ const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, "order", 500)) ?? [];
363
358
  out.push({ ...attr, terms });
364
359
  }
365
360
  return { attributes: out };
@@ -404,10 +399,6 @@ async function submitReview(sr: any, p: any, user: any): Promise<any> {
404
399
  if (!product || product.status !== "publish") {
405
400
  throw new HttpError(404, "Product not found.", "not_found");
406
401
  }
407
- if (product.reviews_allowed === false) {
408
- throw new HttpError(403, "Reviews are disabled for this product.", "reviews_disabled");
409
- }
410
-
411
402
  const reviewerEmail = String(user.email ?? "").trim().toLowerCase();
412
403
  if (!reviewerEmail) {
413
404
  throw new HttpError(403, "Your account has no email address to review under.", "forbidden");
@@ -279,22 +279,16 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
279
279
  let paymentInstructions: any = null;
280
280
  let payment: any = null;
281
281
  switch (gateway.slug) {
282
- case "cod":
283
- await transitionOrder(sr, order, "processing", { settings });
284
- paymentInstructions = { type: "cod", description: gateway.description ?? "Pay with cash upon delivery." };
285
- break;
286
- case "bacs":
282
+ // Settled outside the store, so no money has arrived yet: on-hold, and the
283
+ // merchant moves it on once it has.
284
+ case "offline":
287
285
  await transitionOrder(sr, order, "on-hold", { settings });
288
286
  paymentInstructions = {
289
- type: "bacs",
290
- description: gateway.description ?? "Make your payment directly into our bank account.",
287
+ type: "offline",
288
+ description: gateway.description ?? "Pay outside the store. Your order is confirmed once we receive payment.",
291
289
  account_details: gateway.settings?.account_details ?? [],
292
290
  };
293
291
  break;
294
- case "cheque":
295
- await transitionOrder(sr, order, "on-hold", { settings });
296
- paymentInstructions = { type: "cheque", description: gateway.description ?? "Please send a check to our store address." };
297
- break;
298
292
  default:
299
293
  if (isOnlineGateway(gateway.slug)) {
300
294
  // Online payment: the order stays `pending` and the customer is sent to
@@ -306,7 +300,6 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
306
300
  successUrl: payload.success_url,
307
301
  cancelUrl: payload.cancel_url,
308
302
  returnUrl: payload.return_url,
309
- storeUrl: settings.general?.store_url,
310
303
  returnPath: settings.general?.order_received_path,
311
304
  });
312
305
  const link = await startOnlinePayment(sr, order, {
@@ -148,19 +148,16 @@ export interface CouponLineTotal {
148
148
  }
149
149
 
150
150
  /**
151
- * Apply coupons in order, mutating each line's `.discount`.
152
- * `calc_discounts_sequentially` (general settings): when true each coupon sees
153
- * the line total minus prior discounts; when false all see the original
154
- * subtotal (the default). Line discounts never exceed the line's remaining value.
151
+ * Apply coupons in order, mutating each line's `.discount`. Every coupon is
152
+ * computed off the original line subtotal, not the running total. Line
153
+ * discounts never exceed the line's remaining value.
155
154
  */
156
155
  export function applyCoupons(
157
156
  lines: CouponLineInput[],
158
157
  coupons: any[],
159
- settings: Record<string, any>,
160
158
  ): CouponLineTotal[] {
161
- const sequential = !!settings?.general?.calc_discounts_sequentially;
162
159
  const couponLines: CouponLineTotal[] = [];
163
- const base = (l: CouponLineInput) => Math.max(0, sequential ? l.subtotal - l.discount : l.subtotal);
160
+ const base = (l: CouponLineInput) => Math.max(0, l.subtotal);
164
161
  const remaining = (l: CouponLineInput) => Math.max(0, round2(l.subtotal - l.discount));
165
162
 
166
163
  for (const coupon of coupons || []) {
@@ -66,12 +66,12 @@ const DEFAULT_COPY: Record<string, EmailCopy> = {
66
66
  },
67
67
  };
68
68
 
69
- function substitute(text: string, order: any, general: Record<string, any>): string {
69
+ function substitute(text: string, order: any, storeName: string, general: Record<string, any>): string {
70
70
  const customerName = [order.billing?.first_name, order.billing?.last_name].filter(Boolean).join(" ") || "customer";
71
71
  const out = (text || "")
72
72
  .replaceAll("{order_number}", String(order.order_number ?? ""))
73
73
  .replaceAll("{customer_name}", customerName)
74
- .replaceAll("{store_name}", String(general.store_name || ""))
74
+ .replaceAll("{store_name}", storeName)
75
75
  .replaceAll("{order_total}", formatMoney(order.total ?? 0, general));
76
76
  // An unnamed store must not leave debris behind: "[]: New order", "Your order",
77
77
  // "Invoice #12 from ". A missing name drops out of the sentence instead.
@@ -89,7 +89,7 @@ function esc(s: unknown): string {
89
89
  const cell = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;"';
90
90
  const cellR = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;text-align:right;"';
91
91
 
92
- function addressBlock(title: string, a: any, general: Record<string, any>): string {
92
+ function addressBlock(title: string, a: any): string {
93
93
  if (!a || (!a.address_1 && !a.first_name && !a.city)) return "";
94
94
  const lines = [
95
95
  [a.first_name, a.last_name].filter(Boolean).join(" "),
@@ -121,8 +121,8 @@ export interface RenderedEmail {
121
121
  }
122
122
 
123
123
  /**
124
- * Render an order email. `settings` is the groups object (uses `emails` for
125
- * overrides and `general` for store name / money format).
124
+ * Render an order email. `settings` is the groups object (uses `emails` for the
125
+ * store name and per-type overrides, `general` for money format).
126
126
  */
127
127
  export function renderOrderEmail(
128
128
  type: string,
@@ -131,12 +131,14 @@ export function renderOrderEmail(
131
131
  extra: RenderExtra = {},
132
132
  ): RenderedEmail {
133
133
  const general = settings.general ?? {};
134
- const typeCfg = (settings.emails ?? {})[type] ?? {};
134
+ const emails = settings.emails ?? {};
135
+ const storeName = String(emails.store_name || "");
136
+ const typeCfg = emails[type] ?? {};
135
137
  const copy = DEFAULT_COPY[type] ?? DEFAULT_COPY.customer_invoice;
136
138
 
137
- const subject = substitute(typeCfg.subject || copy.subject, order, general);
138
- const heading = substitute(typeCfg.heading || copy.heading, order, general);
139
- const intro = substitute(copy.intro, order, general);
139
+ const subject = substitute(typeCfg.subject || copy.subject, order, storeName, general);
140
+ const heading = substitute(typeCfg.heading || copy.heading, order, storeName, general);
141
+ const intro = substitute(copy.intro, order, storeName, general);
140
142
  const money = (n: number) => esc(formatMoney(n ?? 0, general));
141
143
 
142
144
  const itemRows = (order.line_items || []).map((li: any) => `
@@ -197,7 +199,7 @@ export function renderOrderEmail(
197
199
  <table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:6px;overflow:hidden;margin:0 auto;">
198
200
  <tr>
199
201
  <td style="background:#557da1;padding:24px;">
200
- <h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600;">${esc(general.store_name || "Your order")}</h1>
202
+ <h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600;">${esc(storeName || "Your order")}</h1>
201
203
  </td>
202
204
  </tr>
203
205
  <tr>
@@ -218,15 +220,15 @@ export function renderOrderEmail(
218
220
  ${payHtml}${customerNoteHtml}${additional}
219
221
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;">
220
222
  <tr>
221
- ${addressBlock("Billing address", order.billing, general)}
222
- ${addressBlock("Shipping address", order.shipping, general)}
223
+ ${addressBlock("Billing address", order.billing)}
224
+ ${addressBlock("Shipping address", order.shipping)}
223
225
  </tr>
224
226
  </table>
225
227
  </td>
226
228
  </tr>
227
229
  <tr>
228
230
  <td style="padding:16px 24px;background:#fafafa;border-top:1px solid #eee;">
229
- <p style="margin:0;font-size:12px;color:#999;">${general.store_name ? `${esc(general.store_name)} — ` : ""}powered by the Base44 commerce template</p>
231
+ <p style="margin:0;font-size:12px;color:#999;">${storeName ? `${esc(storeName)} — ` : ""}powered by the Base44 commerce template</p>
230
232
  </td>
231
233
  </tr>
232
234
  </table>
@@ -120,7 +120,8 @@ export async function sendOrderEmail(
120
120
  }
121
121
 
122
122
  const { subject, html } = renderOrderEmail(type, order, settings, opts.extra ?? {});
123
- const fromName = cfg.from_name || settings.general?.store_name || undefined;
123
+ // Blank sends without from_name, in which case Base44 sends as the app's name.
124
+ const fromName = cfg.store_name || undefined;
124
125
 
125
126
  let anySent = false;
126
127
  for (const { to, target } of recipients) {
@@ -172,7 +173,7 @@ export async function sendStockEmail(sr: any, kind: string, product: any, opts:
172
173
  return;
173
174
  }
174
175
 
175
- const storeName = settings.general?.store_name || "";
176
+ const storeName = settings.emails?.store_name || "";
176
177
  const labels: Record<string, string> = {
177
178
  low_stock: "is low in stock",
178
179
  out_of_stock: "is out of stock",
@@ -182,7 +183,7 @@ export async function sendStockEmail(sr: any, kind: string, product: any, opts:
182
183
  const body = `<p><strong>${product.name}</strong> (${product.sku || "no SKU"}) ${labels[kind] ?? kind}.</p>
183
184
  <p>Remaining stock: ${product.stock_quantity ?? "n/a"}</p>`;
184
185
  try {
185
- await sr.integrations.Core.SendEmail({ to, subject, body, from_name: settings.emails?.from_name });
186
+ await sr.integrations.Core.SendEmail({ to, subject, body, from_name: storeName || undefined });
186
187
  await logEmail(sr, { type: kind, recipient: to, target: "admin", subject, success: true });
187
188
  } catch (e) {
188
189
  await logEmail(sr, {
@@ -194,23 +194,12 @@ export function isAbsoluteUrl(value: unknown): boolean {
194
194
  }
195
195
 
196
196
  /**
197
- * First candidate that is a usable absolute origin, without a trailing slash.
197
+ * The origin of the caller, from the request headers ("" when absent).
198
198
  *
199
199
  * Providers reject relative paths ("Not a valid URL"), so the return URLs have
200
- * to be built from a real origin. Callers pass their candidates in order of
201
- * authority — an explicit `return_url`, the store's configured URL, then the
202
- * origin the request came from (the admin or storefront calling this lives on the
203
- * app's own host, which is exactly where the customer should land back).
200
+ * to be built from a real origin — and the storefront calling this is exactly
201
+ * where the customer should land back.
204
202
  */
205
- export function resolveReturnBase(candidates: Array<unknown>): string {
206
- for (const candidate of candidates) {
207
- const value = String(candidate ?? "").trim();
208
- if (value && isAbsoluteUrl(value)) return value.replace(/\/+$/, "");
209
- }
210
- return "";
211
- }
212
-
213
- /** The origin of the caller, from the request headers ("" when absent). */
214
203
  export function requestOrigin(req: Request): string {
215
204
  const origin = req.headers.get("origin");
216
205
  if (origin && isAbsoluteUrl(origin)) return origin;
@@ -254,8 +243,8 @@ export function orderReturnUrl(base: string, order: any, outcome: "success" | "c
254
243
  * to fix it — better than letting the provider answer "Not a valid URL".
255
244
  *
256
245
  * Precedence: `successUrl`/`cancelUrl` per outcome → `returnUrl` (the return
257
- * page, both outcomes) → `storeUrl`/request origin + `returnPath`. Every result
258
- * carries `order_id`, `order_key` and `payment`.
246
+ * page, both outcomes) → the calling storefront's own origin + `returnPath`.
247
+ * Every result carries `order_id`, `order_key` and `payment`.
259
248
  */
260
249
  export function resolveReturnUrls(opts: {
261
250
  order: any;
@@ -263,10 +252,9 @@ export function resolveReturnUrls(opts: {
263
252
  successUrl?: unknown;
264
253
  cancelUrl?: unknown;
265
254
  returnUrl?: unknown;
266
- storeUrl?: unknown;
267
255
  returnPath?: unknown;
268
256
  }): { successUrl: string; cancelUrl: string } {
269
- const base = resolveReturnBase([opts.storeUrl, opts.req ? requestOrigin(opts.req) : ""]);
257
+ const base = (opts.req ? requestOrigin(opts.req) : "").replace(/\/+$/, "");
270
258
 
271
259
  // A `returnUrl` naming a page is used as-is; a bare origin has no page in it,
272
260
  // so it is treated as a base and gets the store's return path appended.
@@ -289,9 +277,9 @@ export function resolveReturnUrls(opts: {
289
277
  if (!success || !cancel) {
290
278
  throw new HttpError(
291
279
  400,
292
- "Can't build the return URLs for the payment page: pass an absolute success_url/cancel_url (or return_url pointing at your return page), or set the store URL in the store's general settings.",
280
+ "Can't build the return URLs for the payment page: the request carried no origin to return to, so pass an absolute success_url/cancel_url (or a return_url pointing at your return page).",
293
281
  "return_url_required",
294
- { needs: ["success_url", "cancel_url"], hint: "settings.general.store_url + order_received_path" },
282
+ { needs: ["success_url", "cancel_url"], hint: "request origin + settings.general.order_received_path" },
295
283
  );
296
284
  }
297
285
  return { successUrl: success, cancelUrl: cancel };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Product shape predicates.
3
+ *
4
+ * There is no product `type` field: a product sells variants because it carries
5
+ * attributes, and every entry in `attributes[]` is a variant axis. Attaching an
6
+ * attribute is the whole declaration — which also means a product with an axis
7
+ * but no variations is deliberately unsellable (`variation_required`) rather
8
+ * than quietly falling back to the parent price.
9
+ *
10
+ * Descriptive properties are not attributes; they live in `meta_data` (the
11
+ * admin calls them modifiers) and never affect price or stock.
12
+ */
13
+
14
+ /** Does this product sell variants? */
15
+ export function isVariable(product: any): boolean {
16
+ return (product?.attributes ?? []).length > 0;
17
+ }
18
+
19
+ /** The variant axes, in the order the storefront should present them. */
20
+ export function variationAxes(product: any): any[] {
21
+ return (product?.attributes ?? [])
22
+ .slice()
23
+ .sort((a: any, b: any) => (a?.position ?? 0) - (b?.position ?? 0));
24
+ }
@@ -37,14 +37,13 @@ export function storefrontSafeSettings(groups: Record<string, any>): Record<stri
37
37
  const tax = groups.tax ?? {};
38
38
  const inventory = groups.inventory ?? {};
39
39
  return {
40
- store_name: general.store_name ?? "",
40
+ // The one key taken from the emails group: the store's own name is public.
41
+ store_name: groups.emails?.store_name ?? "",
41
42
  currency: general.currency ?? "USD",
42
43
  currency_position: general.currency_position ?? "left",
43
44
  thousand_sep: general.thousand_sep ?? ",",
44
45
  decimal_sep: general.decimal_sep ?? ".",
45
46
  num_decimals: general.num_decimals ?? 2,
46
- enable_taxes: general.enable_taxes ?? true,
47
- enable_coupons: general.enable_coupons ?? true,
48
47
  weight_unit: products.weight_unit ?? "kg",
49
48
  dimension_unit: products.dimension_unit ?? "cm",
50
49
  enable_reviews: products.enable_reviews ?? true,