@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.
- package/README.md +8 -8
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +107 -35
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +47 -35
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +21 -4
- package/skills/commerce/SKILL.md +6 -5
- package/skills/commerce/docs/api-admin.md +16 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +22 -25
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- 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
|
|
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
|
|
181
|
-
} else if (saved
|
|
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
|
|
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
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
423
|
-
fields.
|
|
424
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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"
|
|
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.
|
|
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
|
}
|
|
@@ -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"], "
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
98
|
-
title: "
|
|
99
|
-
description: "
|
|
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: "
|
|
103
|
-
method_description:
|
|
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:
|
|
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).
|
|
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 (
|
|
8
|
-
*
|
|
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("
|
|
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 === "
|
|
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("
|
|
81
|
-
const
|
|
82
|
-
const current = String(
|
|
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(
|
|
85
|
-
values: { ...(
|
|
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 === "
|
|
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",
|
|
156
|
-
{ entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary",
|
|
157
|
-
{ entity: "commerce.Product", record: { name: "__canary",
|
|
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 +
|
|
216
|
-
const
|
|
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
|
-
|
|
220
|
-
for (const
|
|
221
|
-
await track("commerce.ProductAttributeTerm", { ...
|
|
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 =
|
|
227
|
+
const specs = SAMPLE_PRODUCTS;
|
|
229
228
|
|
|
230
229
|
for (const spec of specs) {
|
|
231
|
-
const { key, categories, attributes, default_attributes, variations,
|
|
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:
|
|
244
|
-
name:
|
|
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:
|
|
254
|
-
name:
|
|
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(([
|
|
266
|
-
attribute_id:
|
|
267
|
-
name:
|
|
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
|