@base44/app-plugin-commerce 0.1.3 → 0.1.5
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 +11 -11
- 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 +73 -121
- 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 +50 -166
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- 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/catalog.ts +134 -0
- 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 +23 -7
- package/skills/commerce/SKILL.md +9 -7
- package/skills/commerce/docs/api-admin.md +20 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +8 -7
- package/skills/commerce/post-installation.md +205 -84
- 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/reviews.md +20 -0
- 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
|
@@ -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. `
|
|
73
|
-
*
|
|
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
|
-
"
|
|
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 ?? "
|
|
147
|
-
products.sort(SORTS[sortKey] ?? SORTS["
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"], {},
|
|
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 }, "
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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: "
|
|
290
|
-
description: gateway.description ?? "
|
|
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, {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog write helpers shared by commerce/admin-products and
|
|
3
|
+
* commerce/seed-store, so both produce byte-identical records: slug/code/SKU
|
|
4
|
+
* derivation and uniqueness, price/stock derivation, taxonomy counts, and the
|
|
5
|
+
* variant parent's price/stock rollup.
|
|
6
|
+
*/
|
|
7
|
+
import { HttpError } from "./auth.ts";
|
|
8
|
+
import { round2 } from "./money.ts";
|
|
9
|
+
import { deriveStockStatus } from "./stock.ts";
|
|
10
|
+
import { isVariable } from "./products.ts";
|
|
11
|
+
|
|
12
|
+
export function slugify(name: string): string {
|
|
13
|
+
return String(name || "")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/^-+|-+$/g, "") || "product";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** price/on_sale from the sale window. Mutates rec. */
|
|
21
|
+
export function derivePricing(rec: any): void {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const from = rec.date_on_sale_from ? new Date(rec.date_on_sale_from).getTime() : -Infinity;
|
|
24
|
+
const to = rec.date_on_sale_to ? new Date(rec.date_on_sale_to).getTime() : Infinity;
|
|
25
|
+
const saleActive = rec.sale_price != null && rec.sale_price !== "" && now >= from && now <= to;
|
|
26
|
+
rec.on_sale = !!saleActive;
|
|
27
|
+
const effective = saleActive ? rec.sale_price : rec.regular_price;
|
|
28
|
+
if (effective != null && effective !== "") rec.price = round2(Number(effective));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** stock_status derivation when stock is managed. Mutates rec. */
|
|
32
|
+
export function deriveStock(rec: any, outThreshold: number, managed: boolean): void {
|
|
33
|
+
if (managed) {
|
|
34
|
+
rec.stock_status = deriveStockStatus(
|
|
35
|
+
Number(rec.stock_quantity ?? 0),
|
|
36
|
+
rec.backorders ?? "no",
|
|
37
|
+
outThreshold,
|
|
38
|
+
);
|
|
39
|
+
} else if (!rec.stock_status) {
|
|
40
|
+
rec.stock_status = "instock";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function ensureUniqueSlug(
|
|
45
|
+
sr: any,
|
|
46
|
+
slug: string,
|
|
47
|
+
selfId?: string,
|
|
48
|
+
entity = "commerce.Product",
|
|
49
|
+
scope?: Record<string, any>,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
let candidate = slug;
|
|
52
|
+
for (let i = 2; i < 100; i++) {
|
|
53
|
+
const hits = (await sr.entities[entity].filter({ ...(scope ?? {}), slug: candidate }, undefined, 2)) ?? [];
|
|
54
|
+
if (!hits.some((p: any) => p.id !== selfId)) return candidate;
|
|
55
|
+
candidate = `${slug}-${i}`;
|
|
56
|
+
}
|
|
57
|
+
return `${slug}-${crypto.randomUUID().slice(0, 6)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `code` is what a storefront filter URL carries, so it has to be unique. */
|
|
61
|
+
export async function ensureUniqueCode(sr: any, code: string, selfId?: string): Promise<string> {
|
|
62
|
+
let candidate = code;
|
|
63
|
+
for (let i = 2; i < 100; i++) {
|
|
64
|
+
const hits = (await sr.entities["commerce.ProductAttribute"].filter({ code: candidate }, undefined, 2)) ?? [];
|
|
65
|
+
if (!hits.some((a: any) => a.id !== selfId)) return candidate;
|
|
66
|
+
candidate = `${code}-${i}`;
|
|
67
|
+
}
|
|
68
|
+
return `${code}-${crypto.randomUUID().slice(0, 6)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** SKU must be unique across products AND variations. Throws duplicate_sku. */
|
|
72
|
+
export async function assertUniqueSku(sr: any, sku: string, opts: { productId?: string; variationId?: string }): Promise<void> {
|
|
73
|
+
if (!sku) return;
|
|
74
|
+
const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 2)) ?? [];
|
|
75
|
+
if (prods.some((p: any) => p.id !== opts.productId)) {
|
|
76
|
+
throw new HttpError(409, `SKU "${sku}" is already in use by another product.`, "duplicate_sku");
|
|
77
|
+
}
|
|
78
|
+
const vars = (await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 2)) ?? [];
|
|
79
|
+
if (vars.some((v: any) => v.id !== opts.variationId)) {
|
|
80
|
+
throw new HttpError(409, `SKU "${sku}" is already in use by a product variation.`, "duplicate_sku");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function adjustTermCounts(sr: any, entity: string, prevIds: string[], nextIds: string[]): Promise<void> {
|
|
85
|
+
const added = nextIds.filter((id) => !prevIds.includes(id));
|
|
86
|
+
const removed = prevIds.filter((id) => !nextIds.includes(id));
|
|
87
|
+
for (const id of added) await bumpCount(sr, entity, id, +1);
|
|
88
|
+
for (const id of removed) await bumpCount(sr, entity, id, -1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function bumpCount(sr: any, entity: string, id: string, delta: number): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
const rec = await sr.entities[entity].get(id);
|
|
94
|
+
if (rec) await sr.entities[entity].update(id, { count: Math.max(0, (rec.count ?? 0) + delta) });
|
|
95
|
+
} catch { /* stale reference — recount-terms repairs */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Roll a variant parent's stock and price up from its variations.
|
|
100
|
+
*
|
|
101
|
+
* Price matters beyond display: `list-products` sorts and filters on
|
|
102
|
+
* `product.price`, so a parent left empty sorts as free and drops out of every
|
|
103
|
+
* price filter. Deriving it removes a number nobody could keep in sync by hand —
|
|
104
|
+
* the parent is simply the cheapest thing a customer can actually buy.
|
|
105
|
+
*/
|
|
106
|
+
export async function rollUpParent(sr: any, parent: any, variations: any[]): Promise<void> {
|
|
107
|
+
if (!isVariable(parent)) return;
|
|
108
|
+
const live = variations.filter((v) => (v.status ?? "publish") === "publish");
|
|
109
|
+
const patch: Record<string, any> = {};
|
|
110
|
+
|
|
111
|
+
// manage_stock on the parent means the merchant tracks one pooled quantity,
|
|
112
|
+
// and with it off an explicit stock_status is their own choice to keep.
|
|
113
|
+
if (!parent.manage_stock) {
|
|
114
|
+
const status = live.length && live.every((v) => v.stock_status === "outofstock")
|
|
115
|
+
? "outofstock"
|
|
116
|
+
: "instock";
|
|
117
|
+
if (status !== parent.stock_status) patch.stock_status = status;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cheapest = (key: string): number | null => {
|
|
121
|
+
const values = live.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
|
|
122
|
+
return values.length ? round2(Math.min(...values)) : null;
|
|
123
|
+
};
|
|
124
|
+
const regular = cheapest("regular_price");
|
|
125
|
+
const price = cheapest("price") ?? regular;
|
|
126
|
+
const onSale = live.some((v) => !!v.on_sale);
|
|
127
|
+
if (regular !== (parent.regular_price ?? null)) patch.regular_price = regular;
|
|
128
|
+
if (price !== (parent.price ?? null)) patch.price = price;
|
|
129
|
+
if (onSale !== !!parent.on_sale) patch.on_sale = onSale;
|
|
130
|
+
|
|
131
|
+
if (!Object.keys(patch).length) return;
|
|
132
|
+
await sr.entities["commerce.Product"].update(parent.id, patch);
|
|
133
|
+
Object.assign(parent, patch);
|
|
134
|
+
}
|
|
@@ -148,19 +148,16 @@ export interface CouponLineTotal {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
* Apply coupons in order, mutating each line's `.discount`.
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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,
|
|
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}",
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
222
|
-
${addressBlock("Shipping address", order.shipping
|
|
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;">${
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
201
|
-
*
|
|
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) →
|
|
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 =
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* opportunistically since Base44 has no cron)
|
|
10
10
|
*/
|
|
11
11
|
import { sendStockEmail } from "./emails.ts";
|
|
12
|
+
import { isVariable } from "./products.ts";
|
|
12
13
|
import { getSettings } from "./settings.ts";
|
|
13
14
|
|
|
14
15
|
export interface PurchasableResult {
|
|
@@ -54,15 +55,12 @@ export function checkPurchasable(product: any, variation: any | undefined, qty:
|
|
|
54
55
|
if (!product || product.status !== "publish") {
|
|
55
56
|
return { ok: false, code: "not_published", error: "This product is not available." };
|
|
56
57
|
}
|
|
57
|
-
if (product
|
|
58
|
+
if (isVariable(product) && !variation) {
|
|
58
59
|
return { ok: false, code: "variation_required", error: "Please choose product options." };
|
|
59
60
|
}
|
|
60
61
|
if (variation && variation.status && variation.status !== "publish") {
|
|
61
62
|
return { ok: false, code: "not_published", error: "This product option is not available." };
|
|
62
63
|
}
|
|
63
|
-
if (product.type === "external") {
|
|
64
|
-
return { ok: false, code: "not_purchasable", error: "This product can only be purchased on an external site." };
|
|
65
|
-
}
|
|
66
64
|
const src = variation ?? product;
|
|
67
65
|
if (src.price === undefined || src.price === null) {
|
|
68
66
|
return { ok: false, code: "not_purchasable", error: "This product cannot be purchased (no price set)." };
|