@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Optional demo catalog: 3 categories, 2
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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",
|
|
36
|
-
{ name: "Size",
|
|
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;
|
|
39
|
+
export const SAMPLE_ATTRIBUTE_TERMS: Record<string, Array<{ name: string; order: number }>> = {
|
|
40
40
|
color: [
|
|
41
|
-
{ name: "Red",
|
|
42
|
-
{ name: "Blue",
|
|
43
|
-
{ name: "Black",
|
|
44
|
-
{ name: "Green",
|
|
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",
|
|
48
|
-
{ name: "M",
|
|
49
|
-
{ name: "L",
|
|
50
|
-
{ name: "XL",
|
|
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
|
-
*
|
|
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 —
|
|
83
|
-
* ids at seed time. `variations` uses attribute
|
|
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
|
-
{
|
|
205
|
-
{
|
|
197
|
+
{ code: "color", options: ["Red", "Blue"] },
|
|
198
|
+
{ code: "size", options: ["S", "M", "L"] },
|
|
206
199
|
],
|
|
207
|
-
default_attributes: [{
|
|
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
|
-
{
|
|
231
|
-
{
|
|
222
|
+
{ code: "color", options: ["Black", "Blue", "Green"] },
|
|
223
|
+
{ code: "size", options: ["M", "L", "XL"] },
|
|
232
224
|
],
|
|
233
|
-
default_attributes: [{
|
|
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 —
|
|
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
|
|
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
|
-
{
|
|
250
|
+
{ code: "color", options: ["Black", "Blue", "Green"] },
|
|
260
251
|
],
|
|
261
|
-
default_attributes: [{
|
|
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 & 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. `
|
|
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, {
|
|
@@ -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,
|