@azlib/cms 0.2.0 → 0.4.0
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 +193 -0
- package/dist/index.cjs +1496 -27
- package/dist/index.d.cts +829 -227
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +829 -227
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1487 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -230,6 +230,7 @@ function normalizeConfig(config) {
|
|
|
230
230
|
},
|
|
231
231
|
collections,
|
|
232
232
|
taxonomies: config.taxonomies ?? [],
|
|
233
|
+
plugins: config.plugins ?? [],
|
|
233
234
|
admin: {
|
|
234
235
|
route: config.admin?.route ?? "/admin",
|
|
235
236
|
enableRegistration: config.admin?.enableRegistration ?? false,
|
|
@@ -1246,6 +1247,9 @@ var CMSEngine = class {
|
|
|
1246
1247
|
revisions;
|
|
1247
1248
|
lifecycle;
|
|
1248
1249
|
collections = /* @__PURE__ */ new Map();
|
|
1250
|
+
plugins = /* @__PURE__ */ new Map();
|
|
1251
|
+
customRoutes = /* @__PURE__ */ new Map();
|
|
1252
|
+
pendingPluginSetups = [];
|
|
1249
1253
|
constructor(config = {}, storage, hooks) {
|
|
1250
1254
|
this.config = normalizeConfig(config);
|
|
1251
1255
|
this.hooks = hooks ?? new HooksManager();
|
|
@@ -1258,21 +1262,114 @@ var CMSEngine = class {
|
|
|
1258
1262
|
this.lifecycle = new ContentLifecycle(this.storage, this.hooks);
|
|
1259
1263
|
for (const coll of this.config.collections) this.collections.set(coll.slug, coll);
|
|
1260
1264
|
if (this.config.taxonomies) for (const tax of this.config.taxonomies) this.taxonomies.registerTaxonomy(tax);
|
|
1265
|
+
if (this.config.plugins) for (const plugin of this.config.plugins) this.use(plugin);
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Register a plugin with the CMS engine.
|
|
1269
|
+
*/
|
|
1270
|
+
use(plugin) {
|
|
1271
|
+
if (this.plugins.has(plugin.name)) throw new Error(`[CMSEngine] Plugin '${plugin.name}' is already registered.`);
|
|
1272
|
+
this.plugins.set(plugin.name, plugin);
|
|
1273
|
+
if (plugin.collections) for (const coll of plugin.collections) this.registerCollection(coll);
|
|
1274
|
+
if (plugin.taxonomies) for (const tax of plugin.taxonomies) this.taxonomies.registerTaxonomy(tax);
|
|
1275
|
+
if (plugin.extendCollections) for (const [slug, fields] of Object.entries(plugin.extendCollections)) this.extendCollection(slug, fields);
|
|
1276
|
+
if (plugin.setup) {
|
|
1277
|
+
const context = {
|
|
1278
|
+
engine: this,
|
|
1279
|
+
hooks: this.hooks,
|
|
1280
|
+
storage: this.storage,
|
|
1281
|
+
options: this.options,
|
|
1282
|
+
taxonomies: this.taxonomies,
|
|
1283
|
+
media: this.media,
|
|
1284
|
+
rbac: this.rbac,
|
|
1285
|
+
revisions: this.revisions,
|
|
1286
|
+
lifecycle: this.lifecycle,
|
|
1287
|
+
registerCollection: (c) => this.registerCollection(c),
|
|
1288
|
+
extendCollection: (s, f) => this.extendCollection(s, f),
|
|
1289
|
+
registerTaxonomy: (t) => this.taxonomies.registerTaxonomy(t),
|
|
1290
|
+
registerRoute: (m, p, h) => this.registerRoute(m, p, h)
|
|
1291
|
+
};
|
|
1292
|
+
const setupResult = plugin.setup(context);
|
|
1293
|
+
if (setupResult instanceof Promise) this.pendingPluginSetups.push(setupResult);
|
|
1294
|
+
}
|
|
1295
|
+
return this;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Get all registered plugins.
|
|
1299
|
+
*/
|
|
1300
|
+
getPlugins() {
|
|
1301
|
+
return Array.from(this.plugins.values());
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Get a registered plugin by name.
|
|
1305
|
+
*/
|
|
1306
|
+
getPlugin(name) {
|
|
1307
|
+
return this.plugins.get(name);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Check if a plugin is registered.
|
|
1311
|
+
*/
|
|
1312
|
+
hasPlugin(name) {
|
|
1313
|
+
return this.plugins.has(name);
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Register a collection dynamically.
|
|
1317
|
+
*/
|
|
1318
|
+
registerCollection(coll) {
|
|
1319
|
+
this.collections.set(coll.slug, coll);
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Inject additional field definitions into an existing collection.
|
|
1323
|
+
*/
|
|
1324
|
+
extendCollection(slug, newFields) {
|
|
1325
|
+
const existing = this.collections.get(slug);
|
|
1326
|
+
if (!existing) throw new Error(`[CMSEngine] Cannot extend non-existent collection '${slug}'.`);
|
|
1327
|
+
const existingFieldNames = new Set(existing.fields.map((f) => f.name));
|
|
1328
|
+
const addedFields = newFields.filter((f) => !existingFieldNames.has(f.name));
|
|
1329
|
+
this.collections.set(slug, {
|
|
1330
|
+
...existing,
|
|
1331
|
+
fields: [...existing.fields, ...addedFields]
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Register a custom Web Standard HTTP route on the engine.
|
|
1336
|
+
*/
|
|
1337
|
+
registerRoute(method, path, handler) {
|
|
1338
|
+
const normalizedMethod = method.toUpperCase();
|
|
1339
|
+
let methodMap = this.customRoutes.get(normalizedMethod);
|
|
1340
|
+
if (!methodMap) {
|
|
1341
|
+
methodMap = /* @__PURE__ */ new Map();
|
|
1342
|
+
this.customRoutes.set(normalizedMethod, methodMap);
|
|
1343
|
+
}
|
|
1344
|
+
methodMap.set(path, handler);
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Get all custom routes registered across all HTTP methods.
|
|
1348
|
+
*/
|
|
1349
|
+
getCustomRoutes() {
|
|
1350
|
+
return this.customRoutes;
|
|
1261
1351
|
}
|
|
1262
1352
|
/**
|
|
1263
1353
|
* Initialize storage and trigger bootstrap hooks.
|
|
1264
1354
|
*/
|
|
1265
1355
|
async init() {
|
|
1266
1356
|
await this.storage.init();
|
|
1357
|
+
if (this.pendingPluginSetups.length > 0) {
|
|
1358
|
+
await Promise.all(this.pendingPluginSetups);
|
|
1359
|
+
this.pendingPluginSetups = [];
|
|
1360
|
+
}
|
|
1267
1361
|
if (this.config.site) {
|
|
1268
1362
|
if (!await this.options.has("site_name")) await this.options.set("site_name", this.config.site.name);
|
|
1269
1363
|
}
|
|
1364
|
+
for (const plugin of this.plugins.values()) if (plugin.onInit) await plugin.onInit(this);
|
|
1365
|
+
await this.hooks.doAction("cms.plugins_initialized", this);
|
|
1270
1366
|
await this.hooks.doAction("cms.init", this);
|
|
1271
1367
|
}
|
|
1272
1368
|
/**
|
|
1273
|
-
* Close storage connections.
|
|
1369
|
+
* Close storage connections and run plugin teardown hooks.
|
|
1274
1370
|
*/
|
|
1275
1371
|
async close() {
|
|
1372
|
+
for (const plugin of this.plugins.values()) if (plugin.onClose) await plugin.onClose(this);
|
|
1276
1373
|
await this.storage.close();
|
|
1277
1374
|
await this.hooks.doAction("cms.close", this);
|
|
1278
1375
|
}
|
|
@@ -1298,38 +1395,39 @@ var CMSEngine = class {
|
|
|
1298
1395
|
return {
|
|
1299
1396
|
config: collConfig,
|
|
1300
1397
|
async create(input, authorId = null) {
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
if (
|
|
1304
|
-
if (
|
|
1398
|
+
const filteredInput = await self.hooks.applyFilters("cms.before_create_input", input, { collection: slug });
|
|
1399
|
+
const inputData = { ...filteredInput.data || {} };
|
|
1400
|
+
if (filteredInput.title !== void 0 && inputData.title === void 0) inputData.title = filteredInput.title;
|
|
1401
|
+
if (filteredInput.slug !== void 0 && inputData.slug === void 0) inputData.slug = filteredInput.slug;
|
|
1402
|
+
if (filteredInput.status !== void 0 && inputData.status === void 0) inputData.status = filteredInput.status;
|
|
1305
1403
|
const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, inputData);
|
|
1306
1404
|
if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed for collection '${slug}': ${JSON.stringify(errors)}`);
|
|
1307
|
-
const title =
|
|
1308
|
-
const finalSlug = await resolveUniqueSlug(
|
|
1405
|
+
const title = filteredInput.title ?? normalizedData.title ?? "";
|
|
1406
|
+
const finalSlug = await resolveUniqueSlug(filteredInput.slug ? slugify(filteredInput.slug) : slugify(title) || "item", async (s) => {
|
|
1309
1407
|
return await self.storage.getContentBySlug(slug, s) !== null;
|
|
1310
1408
|
});
|
|
1311
|
-
const status =
|
|
1409
|
+
const status = filteredInput.status ?? (collConfig.draftable ? "draft" : "published");
|
|
1312
1410
|
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1313
1411
|
const item = await self.storage.createContent({
|
|
1314
1412
|
collection: slug,
|
|
1315
1413
|
slug: finalSlug,
|
|
1316
1414
|
title,
|
|
1317
1415
|
status,
|
|
1318
|
-
parentId: collConfig.hierarchical ?
|
|
1416
|
+
parentId: collConfig.hierarchical ? filteredInput.parentId ?? null : null,
|
|
1319
1417
|
authorId,
|
|
1320
1418
|
publishedAt: status === "published" ? nowIso : null,
|
|
1321
|
-
scheduledAt: status === "scheduled" ?
|
|
1419
|
+
scheduledAt: status === "scheduled" ? filteredInput.scheduledAt ?? null : null,
|
|
1322
1420
|
data: normalizedData,
|
|
1323
|
-
terms:
|
|
1421
|
+
terms: filteredInput.terms
|
|
1324
1422
|
});
|
|
1325
|
-
if (
|
|
1326
|
-
const allTermIds = Object.values(
|
|
1423
|
+
if (filteredInput.terms) {
|
|
1424
|
+
const allTermIds = Object.values(filteredInput.terms).flat();
|
|
1327
1425
|
if (allTermIds.length > 0) await self.taxonomies.assignTerms(item.id, allTermIds);
|
|
1328
1426
|
}
|
|
1329
1427
|
if (collConfig.revisions) await self.revisions.createRevision(item, authorId, "Initial creation");
|
|
1330
1428
|
await self.hooks.doAction("cms.content_created", item);
|
|
1331
1429
|
await self.hooks.doAction(`cms.${slug}_created`, item);
|
|
1332
|
-
return item;
|
|
1430
|
+
return self.hooks.applyFilters("cms.after_create_item", item, { collection: slug });
|
|
1333
1431
|
},
|
|
1334
1432
|
async findById(id) {
|
|
1335
1433
|
return self.storage.getContent(slug, id);
|
|
@@ -1338,51 +1436,58 @@ var CMSEngine = class {
|
|
|
1338
1436
|
return self.storage.getContentBySlug(slug, contentSlug);
|
|
1339
1437
|
},
|
|
1340
1438
|
async find(options = {}) {
|
|
1341
|
-
|
|
1439
|
+
const filteredOptions = await self.hooks.applyFilters("cms.find_options", options, { collection: slug });
|
|
1440
|
+
return self.storage.findContent(slug, filteredOptions);
|
|
1342
1441
|
},
|
|
1343
1442
|
async update(id, input, authorId = null, revisionNote) {
|
|
1344
1443
|
const existing = await self.storage.getContent(slug, id);
|
|
1345
1444
|
if (!existing) return null;
|
|
1445
|
+
const filteredInput = await self.hooks.applyFilters("cms.before_update_input", input, {
|
|
1446
|
+
collection: slug,
|
|
1447
|
+
id,
|
|
1448
|
+
existing
|
|
1449
|
+
});
|
|
1346
1450
|
const mergedData = {
|
|
1347
1451
|
...existing.data,
|
|
1348
|
-
...
|
|
1452
|
+
...filteredInput.data || {}
|
|
1349
1453
|
};
|
|
1350
|
-
if (
|
|
1454
|
+
if (filteredInput.title !== void 0 && filteredInput.data?.title === void 0) mergedData.title = filteredInput.title;
|
|
1351
1455
|
else if (existing.title !== void 0 && mergedData.title === void 0) mergedData.title = existing.title;
|
|
1352
|
-
if (
|
|
1456
|
+
if (filteredInput.slug !== void 0 && filteredInput.data?.slug === void 0) mergedData.slug = filteredInput.slug;
|
|
1353
1457
|
else if (existing.slug !== void 0 && mergedData.slug === void 0) mergedData.slug = existing.slug;
|
|
1354
|
-
if (
|
|
1458
|
+
if (filteredInput.status !== void 0 && filteredInput.data?.status === void 0) mergedData.status = filteredInput.status;
|
|
1355
1459
|
else if (existing.status !== void 0 && mergedData.status === void 0) mergedData.status = existing.status;
|
|
1356
1460
|
const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, mergedData);
|
|
1357
1461
|
if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed updating '${slug}': ${JSON.stringify(errors)}`);
|
|
1358
1462
|
let updatedSlug = existing.slug;
|
|
1359
|
-
if (
|
|
1463
|
+
if (filteredInput.slug && filteredInput.slug !== existing.slug) updatedSlug = await resolveUniqueSlug(filteredInput.slug, async (s) => {
|
|
1360
1464
|
const check = await self.storage.getContentBySlug(slug, s);
|
|
1361
1465
|
return check !== null && check.id !== id;
|
|
1362
1466
|
}, id);
|
|
1363
|
-
const nextStatus =
|
|
1467
|
+
const nextStatus = filteredInput.status ?? existing.status;
|
|
1364
1468
|
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1365
1469
|
let publishedAt = existing.publishedAt;
|
|
1366
1470
|
if (nextStatus === "published" && !publishedAt) publishedAt = nowIso;
|
|
1367
1471
|
const updated = await self.storage.updateContent(slug, id, {
|
|
1368
|
-
title:
|
|
1472
|
+
title: filteredInput.title !== void 0 ? filteredInput.title : existing.title,
|
|
1369
1473
|
slug: updatedSlug,
|
|
1370
1474
|
status: nextStatus,
|
|
1371
|
-
parentId: collConfig.hierarchical ?
|
|
1372
|
-
scheduledAt:
|
|
1475
|
+
parentId: collConfig.hierarchical ? filteredInput.parentId !== void 0 ? filteredInput.parentId : existing.parentId : null,
|
|
1476
|
+
scheduledAt: filteredInput.scheduledAt !== void 0 ? filteredInput.scheduledAt : existing.scheduledAt,
|
|
1373
1477
|
publishedAt,
|
|
1374
1478
|
authorId: authorId ?? existing.authorId,
|
|
1375
1479
|
data: normalizedData,
|
|
1376
|
-
terms:
|
|
1480
|
+
terms: filteredInput.terms !== void 0 ? filteredInput.terms : existing.terms
|
|
1377
1481
|
});
|
|
1378
1482
|
if (updated) {
|
|
1379
|
-
if (
|
|
1380
|
-
const allTermIds = Object.values(
|
|
1483
|
+
if (filteredInput.terms) {
|
|
1484
|
+
const allTermIds = Object.values(filteredInput.terms).flat();
|
|
1381
1485
|
await self.taxonomies.assignTerms(updated.id, allTermIds);
|
|
1382
1486
|
}
|
|
1383
1487
|
if (collConfig.revisions) await self.revisions.createRevision(updated, authorId, revisionNote ?? `Updated version ${updated.version}`);
|
|
1384
1488
|
await self.hooks.doAction("cms.content_updated", updated);
|
|
1385
1489
|
await self.hooks.doAction(`cms.${slug}_updated`, updated);
|
|
1490
|
+
return self.hooks.applyFilters("cms.after_update_item", updated, { collection: slug });
|
|
1386
1491
|
}
|
|
1387
1492
|
return updated;
|
|
1388
1493
|
},
|
|
@@ -1439,7 +1544,33 @@ function createCMSEngine(config, storage, hooks) {
|
|
|
1439
1544
|
return new CMSEngine(config, storage, hooks);
|
|
1440
1545
|
}
|
|
1441
1546
|
//#endregion
|
|
1547
|
+
//#region src/core/plugin.ts
|
|
1548
|
+
/**
|
|
1549
|
+
* Type-safe helper for authoring reusable CMS plugins and plugin factories.
|
|
1550
|
+
*/
|
|
1551
|
+
function definePlugin(factory) {
|
|
1552
|
+
return factory;
|
|
1553
|
+
}
|
|
1554
|
+
//#endregion
|
|
1442
1555
|
//#region src/api/router.ts
|
|
1556
|
+
function compilePath(path) {
|
|
1557
|
+
const keys = [];
|
|
1558
|
+
const pattern = path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
|
|
1559
|
+
keys.push(key);
|
|
1560
|
+
return "([^/]+)";
|
|
1561
|
+
});
|
|
1562
|
+
return {
|
|
1563
|
+
regex: new RegExp(`^${pattern}$`),
|
|
1564
|
+
keys
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
function matchRoute(entry, pathname) {
|
|
1568
|
+
const match = pathname.match(entry.regex);
|
|
1569
|
+
if (!match) return null;
|
|
1570
|
+
const params = {};
|
|
1571
|
+
for (let i = 0; i < entry.keys.length; i++) params[entry.keys[i]] = decodeURIComponent(match[i + 1]);
|
|
1572
|
+
return params;
|
|
1573
|
+
}
|
|
1443
1574
|
function jsonResponse(data, status = 200, headers = {}) {
|
|
1444
1575
|
return new Response(JSON.stringify(data), {
|
|
1445
1576
|
status,
|
|
@@ -1454,10 +1585,25 @@ function jsonResponse(data, status = 200, headers = {}) {
|
|
|
1454
1585
|
}
|
|
1455
1586
|
var CMSRouter = class {
|
|
1456
1587
|
engine;
|
|
1588
|
+
localRoutes = [];
|
|
1457
1589
|
constructor(engine) {
|
|
1458
1590
|
this.engine = engine;
|
|
1459
1591
|
}
|
|
1460
1592
|
/**
|
|
1593
|
+
* Register a custom Web Standard route on the router.
|
|
1594
|
+
*/
|
|
1595
|
+
registerRoute(method, path, handler) {
|
|
1596
|
+
const { regex, keys } = compilePath(path);
|
|
1597
|
+
this.localRoutes.push({
|
|
1598
|
+
method: method.toUpperCase(),
|
|
1599
|
+
path,
|
|
1600
|
+
regex,
|
|
1601
|
+
keys,
|
|
1602
|
+
handler
|
|
1603
|
+
});
|
|
1604
|
+
return this;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1461
1607
|
* Universal Web Standards request handler.
|
|
1462
1608
|
*/
|
|
1463
1609
|
async handle(request) {
|
|
@@ -1485,6 +1631,8 @@ var CMSRouter = class {
|
|
|
1485
1631
|
if (pathname.startsWith("/api/taxonomies")) return this.handleTaxonomies(pathname, method, url, request);
|
|
1486
1632
|
if (pathname.startsWith("/api/media")) return this.handleMedia(pathname, method, url, request);
|
|
1487
1633
|
if (pathname.startsWith("/api/content")) return this.handleContent(pathname, method, url, request);
|
|
1634
|
+
const customResponse = await this.handleCustomRoute(pathname, method, url, request);
|
|
1635
|
+
if (customResponse) return customResponse;
|
|
1488
1636
|
return jsonResponse({
|
|
1489
1637
|
error: "Endpoint not found",
|
|
1490
1638
|
path: pathname
|
|
@@ -1493,6 +1641,29 @@ var CMSRouter = class {
|
|
|
1493
1641
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
1494
1642
|
}
|
|
1495
1643
|
}
|
|
1644
|
+
async handleCustomRoute(pathname, method, url, request) {
|
|
1645
|
+
for (const route of this.localRoutes) if (route.method === method) {
|
|
1646
|
+
const params = matchRoute(route, pathname);
|
|
1647
|
+
if (params !== null) {
|
|
1648
|
+
const context = {
|
|
1649
|
+
params,
|
|
1650
|
+
url,
|
|
1651
|
+
engine: this.engine
|
|
1652
|
+
};
|
|
1653
|
+
return await route.handler(request, context);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const engineRoutes = this.engine.getCustomRoutes().get(method);
|
|
1657
|
+
if (engineRoutes) for (const [pathPattern, handler] of engineRoutes) {
|
|
1658
|
+
const params = matchRoute(compilePath(pathPattern), pathname);
|
|
1659
|
+
if (params !== null) return await handler(request, {
|
|
1660
|
+
params,
|
|
1661
|
+
url,
|
|
1662
|
+
engine: this.engine
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1496
1667
|
async handleContent(pathname, method, url, request) {
|
|
1497
1668
|
const parts = pathname.replace(/^\/api\/content\/?/, "").split("/").filter(Boolean);
|
|
1498
1669
|
const collectionSlug = parts[0];
|
|
@@ -1671,6 +1842,12 @@ var CMSClient = class {
|
|
|
1671
1842
|
...options.headers || {}
|
|
1672
1843
|
};
|
|
1673
1844
|
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Access underlying CMSEngine instance when in in-process mode.
|
|
1847
|
+
*/
|
|
1848
|
+
getEngine() {
|
|
1849
|
+
return this.engine;
|
|
1850
|
+
}
|
|
1674
1851
|
collection(slug) {
|
|
1675
1852
|
const self = this;
|
|
1676
1853
|
if (this.engine) {
|
|
@@ -1754,6 +1931,9 @@ var CMSClient = class {
|
|
|
1754
1931
|
return defaultValue;
|
|
1755
1932
|
}
|
|
1756
1933
|
} };
|
|
1934
|
+
/**
|
|
1935
|
+
* Perform an HTTP request against the CMS API (available in remote mode).
|
|
1936
|
+
*/
|
|
1757
1937
|
async request(endpoint, init) {
|
|
1758
1938
|
if (!this.baseUrl) throw new Error(`[CMSClient] baseUrl must be provided when connecting to a remote CMS API.`);
|
|
1759
1939
|
const url = `${this.baseUrl}${endpoint}`;
|
|
@@ -1776,12 +1956,1293 @@ function createCmsClient(options) {
|
|
|
1776
1956
|
return new CMSClient(options);
|
|
1777
1957
|
}
|
|
1778
1958
|
//#endregion
|
|
1959
|
+
//#region src/plugins/ecommerce/schemas.ts
|
|
1960
|
+
/**
|
|
1961
|
+
* Creates the collection configuration for Products.
|
|
1962
|
+
*/
|
|
1963
|
+
function createProductCollection(options = {}) {
|
|
1964
|
+
const slug = options.productCollectionSlug ?? "products";
|
|
1965
|
+
const catSlug = options.categoriesTaxonomySlug ?? "product_categories";
|
|
1966
|
+
const tagSlug = options.tagsTaxonomySlug ?? "product_tags";
|
|
1967
|
+
const brandSlug = options.brandsTaxonomySlug ?? "product_brands";
|
|
1968
|
+
const defaultCurrency = options.defaultCurrency ?? "USD";
|
|
1969
|
+
return collection({
|
|
1970
|
+
slug,
|
|
1971
|
+
label: "Products",
|
|
1972
|
+
singularLabel: "Product",
|
|
1973
|
+
description: "Catalog products with pricing, inventory, images, and variants",
|
|
1974
|
+
timestamps: true,
|
|
1975
|
+
revisions: true,
|
|
1976
|
+
draftable: true,
|
|
1977
|
+
taxonomies: [
|
|
1978
|
+
catSlug,
|
|
1979
|
+
tagSlug,
|
|
1980
|
+
brandSlug
|
|
1981
|
+
],
|
|
1982
|
+
defaultSort: {
|
|
1983
|
+
field: "createdAt",
|
|
1984
|
+
direction: "desc"
|
|
1985
|
+
},
|
|
1986
|
+
fields: [
|
|
1987
|
+
fields.text({
|
|
1988
|
+
name: "title",
|
|
1989
|
+
label: "Product Title",
|
|
1990
|
+
required: true
|
|
1991
|
+
}),
|
|
1992
|
+
fields.slug({
|
|
1993
|
+
from: "title",
|
|
1994
|
+
unique: true
|
|
1995
|
+
}),
|
|
1996
|
+
fields.text({
|
|
1997
|
+
name: "sku",
|
|
1998
|
+
label: "SKU",
|
|
1999
|
+
description: "Stock Keeping Unit identifier"
|
|
2000
|
+
}),
|
|
2001
|
+
fields.number({
|
|
2002
|
+
name: "price",
|
|
2003
|
+
label: "Price",
|
|
2004
|
+
required: true,
|
|
2005
|
+
min: 0
|
|
2006
|
+
}),
|
|
2007
|
+
fields.number({
|
|
2008
|
+
name: "compareAtPrice",
|
|
2009
|
+
label: "Compare At Price",
|
|
2010
|
+
description: "Original retail price for showing strike-through discounts",
|
|
2011
|
+
min: 0
|
|
2012
|
+
}),
|
|
2013
|
+
fields.number({
|
|
2014
|
+
name: "costPrice",
|
|
2015
|
+
label: "Cost Price",
|
|
2016
|
+
description: "Wholesale or production cost for margin tracking",
|
|
2017
|
+
min: 0
|
|
2018
|
+
}),
|
|
2019
|
+
fields.text({
|
|
2020
|
+
name: "currency",
|
|
2021
|
+
label: "Currency",
|
|
2022
|
+
defaultValue: defaultCurrency
|
|
2023
|
+
}),
|
|
2024
|
+
fields.number({
|
|
2025
|
+
name: "stock",
|
|
2026
|
+
label: "Inventory Quantity",
|
|
2027
|
+
defaultValue: 0,
|
|
2028
|
+
min: 0
|
|
2029
|
+
}),
|
|
2030
|
+
fields.boolean({
|
|
2031
|
+
name: "trackInventory",
|
|
2032
|
+
label: "Track Inventory",
|
|
2033
|
+
defaultValue: options.inventoryManagement ?? true
|
|
2034
|
+
}),
|
|
2035
|
+
fields.select({
|
|
2036
|
+
name: "status",
|
|
2037
|
+
label: "Status",
|
|
2038
|
+
options: [
|
|
2039
|
+
"draft",
|
|
2040
|
+
"published",
|
|
2041
|
+
"out_of_stock",
|
|
2042
|
+
"archived"
|
|
2043
|
+
],
|
|
2044
|
+
defaultValue: "draft"
|
|
2045
|
+
}),
|
|
2046
|
+
fields.richText({
|
|
2047
|
+
name: "description",
|
|
2048
|
+
label: "Full Description"
|
|
2049
|
+
}),
|
|
2050
|
+
fields.text({
|
|
2051
|
+
name: "shortDescription",
|
|
2052
|
+
label: "Short Description"
|
|
2053
|
+
}),
|
|
2054
|
+
fields.image({
|
|
2055
|
+
name: "featuredImage",
|
|
2056
|
+
label: "Featured Image"
|
|
2057
|
+
}),
|
|
2058
|
+
fields.json({
|
|
2059
|
+
name: "gallery",
|
|
2060
|
+
label: "Image Gallery",
|
|
2061
|
+
defaultValue: []
|
|
2062
|
+
}),
|
|
2063
|
+
fields.json({
|
|
2064
|
+
name: "variants",
|
|
2065
|
+
label: "Product Variants",
|
|
2066
|
+
defaultValue: []
|
|
2067
|
+
}),
|
|
2068
|
+
fields.json({
|
|
2069
|
+
name: "attributes",
|
|
2070
|
+
label: "Specifications / Attributes",
|
|
2071
|
+
defaultValue: {}
|
|
2072
|
+
}),
|
|
2073
|
+
fields.number({
|
|
2074
|
+
name: "weight",
|
|
2075
|
+
label: "Weight",
|
|
2076
|
+
min: 0
|
|
2077
|
+
})
|
|
2078
|
+
]
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Creates the collection configuration for Discounts / Coupons.
|
|
2083
|
+
*/
|
|
2084
|
+
function createDiscountCollection(options = {}) {
|
|
2085
|
+
return collection({
|
|
2086
|
+
slug: options.discountCollectionSlug ?? "discounts",
|
|
2087
|
+
label: "Discounts",
|
|
2088
|
+
singularLabel: "Discount",
|
|
2089
|
+
description: "Promotional discount codes and coupons",
|
|
2090
|
+
timestamps: true,
|
|
2091
|
+
revisions: true,
|
|
2092
|
+
draftable: false,
|
|
2093
|
+
defaultSort: {
|
|
2094
|
+
field: "createdAt",
|
|
2095
|
+
direction: "desc"
|
|
2096
|
+
},
|
|
2097
|
+
fields: [
|
|
2098
|
+
fields.text({
|
|
2099
|
+
name: "title",
|
|
2100
|
+
label: "Discount Name",
|
|
2101
|
+
required: true
|
|
2102
|
+
}),
|
|
2103
|
+
fields.text({
|
|
2104
|
+
name: "code",
|
|
2105
|
+
label: "Coupon Code",
|
|
2106
|
+
required: true,
|
|
2107
|
+
unique: true
|
|
2108
|
+
}),
|
|
2109
|
+
fields.select({
|
|
2110
|
+
name: "discountType",
|
|
2111
|
+
label: "Discount Type",
|
|
2112
|
+
options: [
|
|
2113
|
+
"percentage",
|
|
2114
|
+
"fixed_amount",
|
|
2115
|
+
"free_shipping"
|
|
2116
|
+
],
|
|
2117
|
+
defaultValue: "percentage"
|
|
2118
|
+
}),
|
|
2119
|
+
fields.number({
|
|
2120
|
+
name: "value",
|
|
2121
|
+
label: "Discount Value",
|
|
2122
|
+
required: true,
|
|
2123
|
+
min: 0
|
|
2124
|
+
}),
|
|
2125
|
+
fields.number({
|
|
2126
|
+
name: "minOrderAmount",
|
|
2127
|
+
label: "Minimum Order Amount",
|
|
2128
|
+
min: 0
|
|
2129
|
+
}),
|
|
2130
|
+
fields.number({
|
|
2131
|
+
name: "maxDiscountAmount",
|
|
2132
|
+
label: "Maximum Discount Cap",
|
|
2133
|
+
min: 0
|
|
2134
|
+
}),
|
|
2135
|
+
fields.number({
|
|
2136
|
+
name: "maxUses",
|
|
2137
|
+
label: "Maximum Uses",
|
|
2138
|
+
min: 1
|
|
2139
|
+
}),
|
|
2140
|
+
fields.number({
|
|
2141
|
+
name: "usedCount",
|
|
2142
|
+
label: "Times Used",
|
|
2143
|
+
defaultValue: 0,
|
|
2144
|
+
min: 0
|
|
2145
|
+
}),
|
|
2146
|
+
fields.date({
|
|
2147
|
+
name: "startDate",
|
|
2148
|
+
label: "Start Date"
|
|
2149
|
+
}),
|
|
2150
|
+
fields.date({
|
|
2151
|
+
name: "endDate",
|
|
2152
|
+
label: "Expiration Date"
|
|
2153
|
+
}),
|
|
2154
|
+
fields.select({
|
|
2155
|
+
name: "status",
|
|
2156
|
+
label: "Status",
|
|
2157
|
+
options: [
|
|
2158
|
+
"active",
|
|
2159
|
+
"disabled",
|
|
2160
|
+
"expired"
|
|
2161
|
+
],
|
|
2162
|
+
defaultValue: "active"
|
|
2163
|
+
}),
|
|
2164
|
+
fields.json({
|
|
2165
|
+
name: "appliesToProductIds",
|
|
2166
|
+
label: "Specific Product IDs",
|
|
2167
|
+
defaultValue: []
|
|
2168
|
+
}),
|
|
2169
|
+
fields.json({
|
|
2170
|
+
name: "appliesToCategoryIds",
|
|
2171
|
+
label: "Specific Category Term IDs",
|
|
2172
|
+
defaultValue: []
|
|
2173
|
+
})
|
|
2174
|
+
]
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Creates the collection configuration for Orders.
|
|
2179
|
+
*/
|
|
2180
|
+
function createOrderCollection(options = {}) {
|
|
2181
|
+
const slug = options.orderCollectionSlug ?? "orders";
|
|
2182
|
+
const defaultCurrency = options.defaultCurrency ?? "USD";
|
|
2183
|
+
return collection({
|
|
2184
|
+
slug,
|
|
2185
|
+
label: "Orders",
|
|
2186
|
+
singularLabel: "Order",
|
|
2187
|
+
description: "Customer orders, status lifecycle, and line items",
|
|
2188
|
+
timestamps: true,
|
|
2189
|
+
revisions: true,
|
|
2190
|
+
draftable: false,
|
|
2191
|
+
defaultSort: {
|
|
2192
|
+
field: "createdAt",
|
|
2193
|
+
direction: "desc"
|
|
2194
|
+
},
|
|
2195
|
+
fields: [
|
|
2196
|
+
fields.text({
|
|
2197
|
+
name: "orderNumber",
|
|
2198
|
+
label: "Order Number",
|
|
2199
|
+
required: true,
|
|
2200
|
+
unique: true
|
|
2201
|
+
}),
|
|
2202
|
+
fields.text({
|
|
2203
|
+
name: "customerEmail",
|
|
2204
|
+
label: "Customer Email",
|
|
2205
|
+
required: true
|
|
2206
|
+
}),
|
|
2207
|
+
fields.text({
|
|
2208
|
+
name: "customerName",
|
|
2209
|
+
label: "Customer Name"
|
|
2210
|
+
}),
|
|
2211
|
+
fields.select({
|
|
2212
|
+
name: "status",
|
|
2213
|
+
label: "Status",
|
|
2214
|
+
options: [
|
|
2215
|
+
"pending",
|
|
2216
|
+
"paid",
|
|
2217
|
+
"processing",
|
|
2218
|
+
"shipped",
|
|
2219
|
+
"delivered",
|
|
2220
|
+
"cancelled",
|
|
2221
|
+
"refunded"
|
|
2222
|
+
],
|
|
2223
|
+
defaultValue: "pending"
|
|
2224
|
+
}),
|
|
2225
|
+
fields.text({
|
|
2226
|
+
name: "currency",
|
|
2227
|
+
label: "Currency",
|
|
2228
|
+
defaultValue: defaultCurrency
|
|
2229
|
+
}),
|
|
2230
|
+
fields.json({
|
|
2231
|
+
name: "items",
|
|
2232
|
+
label: "Line Items",
|
|
2233
|
+
defaultValue: []
|
|
2234
|
+
}),
|
|
2235
|
+
fields.number({
|
|
2236
|
+
name: "subtotal",
|
|
2237
|
+
label: "Subtotal",
|
|
2238
|
+
defaultValue: 0,
|
|
2239
|
+
min: 0
|
|
2240
|
+
}),
|
|
2241
|
+
fields.number({
|
|
2242
|
+
name: "discountTotal",
|
|
2243
|
+
label: "Discount Total",
|
|
2244
|
+
defaultValue: 0,
|
|
2245
|
+
min: 0
|
|
2246
|
+
}),
|
|
2247
|
+
fields.text({
|
|
2248
|
+
name: "discountCode",
|
|
2249
|
+
label: "Discount Code"
|
|
2250
|
+
}),
|
|
2251
|
+
fields.number({
|
|
2252
|
+
name: "shippingTotal",
|
|
2253
|
+
label: "Shipping Total",
|
|
2254
|
+
defaultValue: 0,
|
|
2255
|
+
min: 0
|
|
2256
|
+
}),
|
|
2257
|
+
fields.number({
|
|
2258
|
+
name: "taxTotal",
|
|
2259
|
+
label: "Tax Total",
|
|
2260
|
+
defaultValue: 0,
|
|
2261
|
+
min: 0
|
|
2262
|
+
}),
|
|
2263
|
+
fields.number({
|
|
2264
|
+
name: "total",
|
|
2265
|
+
label: "Grand Total",
|
|
2266
|
+
defaultValue: 0,
|
|
2267
|
+
min: 0
|
|
2268
|
+
}),
|
|
2269
|
+
fields.json({
|
|
2270
|
+
name: "shippingAddress",
|
|
2271
|
+
label: "Shipping Address"
|
|
2272
|
+
}),
|
|
2273
|
+
fields.json({
|
|
2274
|
+
name: "billingAddress",
|
|
2275
|
+
label: "Billing Address"
|
|
2276
|
+
}),
|
|
2277
|
+
fields.text({
|
|
2278
|
+
name: "paymentMethod",
|
|
2279
|
+
label: "Payment Method"
|
|
2280
|
+
}),
|
|
2281
|
+
fields.text({
|
|
2282
|
+
name: "notes",
|
|
2283
|
+
label: "Notes"
|
|
2284
|
+
})
|
|
2285
|
+
]
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Creates the standard e-commerce taxonomies: product categories, tags, and brands.
|
|
2290
|
+
*/
|
|
2291
|
+
function createEcommerceTaxonomies(options = {}) {
|
|
2292
|
+
const productSlug = options.productCollectionSlug ?? "products";
|
|
2293
|
+
const catSlug = options.categoriesTaxonomySlug ?? "product_categories";
|
|
2294
|
+
const tagSlug = options.tagsTaxonomySlug ?? "product_tags";
|
|
2295
|
+
const brandSlug = options.brandsTaxonomySlug ?? "product_brands";
|
|
2296
|
+
return [
|
|
2297
|
+
{
|
|
2298
|
+
slug: catSlug,
|
|
2299
|
+
label: "Product Categories",
|
|
2300
|
+
singularLabel: "Product Category",
|
|
2301
|
+
hierarchical: true,
|
|
2302
|
+
postTypes: [productSlug],
|
|
2303
|
+
description: "Hierarchical categories and catalog collections for organizing products"
|
|
2304
|
+
},
|
|
2305
|
+
{
|
|
2306
|
+
slug: tagSlug,
|
|
2307
|
+
label: "Product Tags",
|
|
2308
|
+
singularLabel: "Product Tag",
|
|
2309
|
+
hierarchical: false,
|
|
2310
|
+
postTypes: [productSlug],
|
|
2311
|
+
description: "Flat keyword tags for filtering products"
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
slug: brandSlug,
|
|
2315
|
+
label: "Brands",
|
|
2316
|
+
singularLabel: "Brand",
|
|
2317
|
+
hierarchical: false,
|
|
2318
|
+
postTypes: [productSlug],
|
|
2319
|
+
description: "Product manufacturers or brand labels"
|
|
2320
|
+
}
|
|
2321
|
+
];
|
|
2322
|
+
}
|
|
2323
|
+
//#endregion
|
|
2324
|
+
//#region src/plugins/ecommerce/service.ts
|
|
2325
|
+
var EcommerceService = class {
|
|
2326
|
+
engine;
|
|
2327
|
+
options;
|
|
2328
|
+
productSlug;
|
|
2329
|
+
discountSlug;
|
|
2330
|
+
orderSlug;
|
|
2331
|
+
categoriesTaxonomy;
|
|
2332
|
+
tagsTaxonomy;
|
|
2333
|
+
brandsTaxonomy;
|
|
2334
|
+
defaultCurrency;
|
|
2335
|
+
inventoryManagement;
|
|
2336
|
+
constructor(engine, options = {}) {
|
|
2337
|
+
this.engine = engine;
|
|
2338
|
+
this.options = options;
|
|
2339
|
+
this.productSlug = options.productCollectionSlug ?? "products";
|
|
2340
|
+
this.discountSlug = options.discountCollectionSlug ?? "discounts";
|
|
2341
|
+
this.orderSlug = options.orderCollectionSlug ?? "orders";
|
|
2342
|
+
this.categoriesTaxonomy = options.categoriesTaxonomySlug ?? "product_categories";
|
|
2343
|
+
this.tagsTaxonomy = options.tagsTaxonomySlug ?? "product_tags";
|
|
2344
|
+
this.brandsTaxonomy = options.brandsTaxonomySlug ?? "product_brands";
|
|
2345
|
+
this.defaultCurrency = options.defaultCurrency ?? "USD";
|
|
2346
|
+
this.inventoryManagement = options.inventoryManagement ?? true;
|
|
2347
|
+
}
|
|
2348
|
+
get productsCollection() {
|
|
2349
|
+
return this.engine.collection(this.productSlug);
|
|
2350
|
+
}
|
|
2351
|
+
get discountsCollection() {
|
|
2352
|
+
return this.engine.collection(this.discountSlug);
|
|
2353
|
+
}
|
|
2354
|
+
get ordersCollection() {
|
|
2355
|
+
return this.engine.collection(this.orderSlug);
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Create a new product in the catalog.
|
|
2359
|
+
*/
|
|
2360
|
+
async createProduct(input, authorId) {
|
|
2361
|
+
const productData = {
|
|
2362
|
+
price: input.price,
|
|
2363
|
+
compareAtPrice: input.compareAtPrice,
|
|
2364
|
+
costPrice: input.costPrice,
|
|
2365
|
+
sku: input.sku,
|
|
2366
|
+
currency: input.currency ?? this.defaultCurrency,
|
|
2367
|
+
stock: input.stock ?? 0,
|
|
2368
|
+
trackInventory: input.trackInventory ?? this.inventoryManagement,
|
|
2369
|
+
status: input.status ?? "draft",
|
|
2370
|
+
description: input.description,
|
|
2371
|
+
shortDescription: input.shortDescription,
|
|
2372
|
+
featuredImage: input.featuredImage,
|
|
2373
|
+
gallery: input.gallery ?? [],
|
|
2374
|
+
variants: input.variants ?? [],
|
|
2375
|
+
attributes: input.attributes ?? {},
|
|
2376
|
+
weight: input.weight
|
|
2377
|
+
};
|
|
2378
|
+
const product = await this.productsCollection.create({
|
|
2379
|
+
title: input.title,
|
|
2380
|
+
slug: input.slug,
|
|
2381
|
+
status: input.status === "published" ? "published" : "draft",
|
|
2382
|
+
data: productData
|
|
2383
|
+
}, authorId);
|
|
2384
|
+
if (input.categoryIds && input.categoryIds.length > 0) await this.engine.taxonomies.assignTerms(product.id, input.categoryIds);
|
|
2385
|
+
if (input.tagIds && input.tagIds.length > 0) await this.engine.taxonomies.assignTerms(product.id, input.tagIds);
|
|
2386
|
+
if (input.brandIds && input.brandIds.length > 0) await this.engine.taxonomies.assignTerms(product.id, input.brandIds);
|
|
2387
|
+
await this.engine.hooks.doAction("ecommerce.product_created", product);
|
|
2388
|
+
return product;
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Update an existing product.
|
|
2392
|
+
*/
|
|
2393
|
+
async updateProduct(id, input, authorId) {
|
|
2394
|
+
const existing = await this.getProduct(id);
|
|
2395
|
+
if (!existing) return null;
|
|
2396
|
+
const updatedData = {
|
|
2397
|
+
...existing.data,
|
|
2398
|
+
...input.price !== void 0 ? { price: input.price } : {},
|
|
2399
|
+
...input.compareAtPrice !== void 0 ? { compareAtPrice: input.compareAtPrice } : {},
|
|
2400
|
+
...input.costPrice !== void 0 ? { costPrice: input.costPrice } : {},
|
|
2401
|
+
...input.sku !== void 0 ? { sku: input.sku } : {},
|
|
2402
|
+
...input.currency !== void 0 ? { currency: input.currency } : {},
|
|
2403
|
+
...input.stock !== void 0 ? { stock: input.stock } : {},
|
|
2404
|
+
...input.trackInventory !== void 0 ? { trackInventory: input.trackInventory } : {},
|
|
2405
|
+
...input.status !== void 0 ? { status: input.status } : {},
|
|
2406
|
+
...input.description !== void 0 ? { description: input.description } : {},
|
|
2407
|
+
...input.shortDescription !== void 0 ? { shortDescription: input.shortDescription } : {},
|
|
2408
|
+
...input.featuredImage !== void 0 ? { featuredImage: input.featuredImage } : {},
|
|
2409
|
+
...input.gallery !== void 0 ? { gallery: input.gallery } : {},
|
|
2410
|
+
...input.variants !== void 0 ? { variants: input.variants } : {},
|
|
2411
|
+
...input.attributes !== void 0 ? { attributes: input.attributes } : {},
|
|
2412
|
+
...input.weight !== void 0 ? { weight: input.weight } : {}
|
|
2413
|
+
};
|
|
2414
|
+
const updated = await this.productsCollection.update(id, {
|
|
2415
|
+
...input.title ? { title: input.title } : {},
|
|
2416
|
+
...input.slug ? { slug: input.slug } : {},
|
|
2417
|
+
...input.status === "published" ? { status: "published" } : input.status ? { status: "draft" } : {},
|
|
2418
|
+
data: updatedData
|
|
2419
|
+
}, authorId);
|
|
2420
|
+
if (updated) {
|
|
2421
|
+
if (input.categoryIds) await this.engine.taxonomies.assignTerms(id, input.categoryIds);
|
|
2422
|
+
if (input.tagIds) await this.engine.taxonomies.assignTerms(id, input.tagIds);
|
|
2423
|
+
if (input.brandIds) await this.engine.taxonomies.assignTerms(id, input.brandIds);
|
|
2424
|
+
await this.engine.hooks.doAction("ecommerce.product_updated", updated);
|
|
2425
|
+
}
|
|
2426
|
+
return updated;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Get a product by ID.
|
|
2430
|
+
*/
|
|
2431
|
+
async getProduct(id) {
|
|
2432
|
+
return this.productsCollection.findById(id);
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Get a product by its URL slug.
|
|
2436
|
+
*/
|
|
2437
|
+
async getProductBySlug(slug) {
|
|
2438
|
+
return this.productsCollection.findBySlug(slug);
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Delete a product by ID.
|
|
2442
|
+
*/
|
|
2443
|
+
async deleteProduct(id) {
|
|
2444
|
+
const deleted = await this.productsCollection.delete(id);
|
|
2445
|
+
if (deleted) await this.engine.hooks.doAction("ecommerce.product_deleted", id);
|
|
2446
|
+
return deleted;
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* List and filter catalog products.
|
|
2450
|
+
*/
|
|
2451
|
+
async listProducts(query = {}) {
|
|
2452
|
+
let termIds;
|
|
2453
|
+
if (query.categorySlug) {
|
|
2454
|
+
const term = await this.engine.taxonomies.getTermBySlug(this.categoriesTaxonomy, query.categorySlug);
|
|
2455
|
+
if (term) termIds = [term.id];
|
|
2456
|
+
else return {
|
|
2457
|
+
items: [],
|
|
2458
|
+
total: 0,
|
|
2459
|
+
limit: query.limit ?? 20,
|
|
2460
|
+
offset: query.offset ?? 0,
|
|
2461
|
+
hasMore: false
|
|
2462
|
+
};
|
|
2463
|
+
} else if (query.categoryId) termIds = [query.categoryId];
|
|
2464
|
+
if (query.tagSlug) {
|
|
2465
|
+
const term = await this.engine.taxonomies.getTermBySlug(this.tagsTaxonomy, query.tagSlug);
|
|
2466
|
+
if (term) termIds = termIds ? [...termIds, term.id] : [term.id];
|
|
2467
|
+
}
|
|
2468
|
+
if (query.brandSlug) {
|
|
2469
|
+
const term = await this.engine.taxonomies.getTermBySlug(this.brandsTaxonomy, query.brandSlug);
|
|
2470
|
+
if (term) termIds = termIds ? [...termIds, term.id] : [term.id];
|
|
2471
|
+
}
|
|
2472
|
+
const contentStatus = query.status === "draft" ? "draft" : query.status === "published" ? "published" : void 0;
|
|
2473
|
+
const result = await this.productsCollection.find({
|
|
2474
|
+
search: query.search,
|
|
2475
|
+
termIds,
|
|
2476
|
+
status: contentStatus ?? "published",
|
|
2477
|
+
limit: query.limit ?? 20,
|
|
2478
|
+
offset: query.offset ?? 0,
|
|
2479
|
+
orderBy: query.orderBy === "price" || query.orderBy === "stock" ? void 0 : query.orderBy,
|
|
2480
|
+
orderDirection: query.orderDirection ?? "desc"
|
|
2481
|
+
});
|
|
2482
|
+
let items = result.items;
|
|
2483
|
+
if (query.status) {
|
|
2484
|
+
const statuses = Array.isArray(query.status) ? query.status : [query.status];
|
|
2485
|
+
items = items.filter((p) => statuses.includes(p.data.status));
|
|
2486
|
+
}
|
|
2487
|
+
if (query.minPrice !== void 0) items = items.filter((p) => p.data.price >= query.minPrice);
|
|
2488
|
+
if (query.maxPrice !== void 0) items = items.filter((p) => p.data.price <= query.maxPrice);
|
|
2489
|
+
if (query.inStock) items = items.filter((p) => !p.data.trackInventory || p.data.stock > 0);
|
|
2490
|
+
if (query.orderBy === "price") items.sort((a, b) => query.orderDirection === "asc" ? a.data.price - b.data.price : b.data.price - a.data.price);
|
|
2491
|
+
else if (query.orderBy === "stock") items.sort((a, b) => query.orderDirection === "asc" ? a.data.stock - b.data.stock : b.data.stock - a.data.stock);
|
|
2492
|
+
return {
|
|
2493
|
+
items,
|
|
2494
|
+
total: items.length,
|
|
2495
|
+
limit: result.limit,
|
|
2496
|
+
offset: result.offset,
|
|
2497
|
+
hasMore: result.offset + items.length < result.total
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Upload and link a product image to its gallery and featured slot.
|
|
2502
|
+
*/
|
|
2503
|
+
async uploadProductImage(productId, file, authorId) {
|
|
2504
|
+
const product = await this.getProduct(productId);
|
|
2505
|
+
if (!product) throw new Error(`[EcommerceService] Product '${productId}' not found.`);
|
|
2506
|
+
const media = await this.engine.media.upload({
|
|
2507
|
+
filename: file.filename,
|
|
2508
|
+
mimeType: file.mimeType,
|
|
2509
|
+
sizeBytes: file.sizeBytes,
|
|
2510
|
+
url: file.url,
|
|
2511
|
+
altText: file.altText ?? product.title,
|
|
2512
|
+
caption: file.caption,
|
|
2513
|
+
width: file.width,
|
|
2514
|
+
height: file.height
|
|
2515
|
+
}, authorId);
|
|
2516
|
+
const gallery = [...product.data.gallery || []];
|
|
2517
|
+
gallery.push({
|
|
2518
|
+
id: media.id,
|
|
2519
|
+
url: media.url,
|
|
2520
|
+
altText: media.altText,
|
|
2521
|
+
caption: media.caption,
|
|
2522
|
+
width: media.width,
|
|
2523
|
+
height: media.height
|
|
2524
|
+
});
|
|
2525
|
+
const isFeatured = file.isFeatured ?? !product.data.featuredImage;
|
|
2526
|
+
const updated = await this.productsCollection.update(productId, { data: {
|
|
2527
|
+
...product.data,
|
|
2528
|
+
gallery,
|
|
2529
|
+
...isFeatured ? { featuredImage: media.url } : {}
|
|
2530
|
+
} });
|
|
2531
|
+
await this.engine.hooks.doAction("ecommerce.product_image_added", {
|
|
2532
|
+
product: updated ?? product,
|
|
2533
|
+
media
|
|
2534
|
+
});
|
|
2535
|
+
return {
|
|
2536
|
+
media,
|
|
2537
|
+
product: updated ?? product
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Adjust inventory stock for a product or variant.
|
|
2542
|
+
*/
|
|
2543
|
+
async adjustStock(productId, delta, variantId) {
|
|
2544
|
+
const product = await this.getProduct(productId);
|
|
2545
|
+
if (!product) return null;
|
|
2546
|
+
if (!product.data.trackInventory) return product;
|
|
2547
|
+
let newStock = product.data.stock;
|
|
2548
|
+
const variants = [...product.data.variants || []];
|
|
2549
|
+
if (variantId) {
|
|
2550
|
+
const idx = variants.findIndex((v) => v.id === variantId);
|
|
2551
|
+
if (idx !== -1) {
|
|
2552
|
+
const currentVariantStock = variants[idx].stock ?? 0;
|
|
2553
|
+
const updatedVariantStock = Math.max(0, currentVariantStock + delta);
|
|
2554
|
+
variants[idx] = {
|
|
2555
|
+
...variants[idx],
|
|
2556
|
+
stock: updatedVariantStock
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
} else newStock = Math.max(0, product.data.stock + delta);
|
|
2560
|
+
const newStatus = newStock === 0 && product.data.status === "published" ? "out_of_stock" : product.data.status === "out_of_stock" && newStock > 0 ? "published" : product.data.status;
|
|
2561
|
+
const updated = await this.productsCollection.update(productId, { data: {
|
|
2562
|
+
...product.data,
|
|
2563
|
+
stock: newStock,
|
|
2564
|
+
variants,
|
|
2565
|
+
status: newStatus
|
|
2566
|
+
} });
|
|
2567
|
+
await this.engine.hooks.doAction("ecommerce.stock_changed", {
|
|
2568
|
+
product: updated ?? product,
|
|
2569
|
+
delta,
|
|
2570
|
+
variantId,
|
|
2571
|
+
oldStock: product.data.stock,
|
|
2572
|
+
newStock
|
|
2573
|
+
});
|
|
2574
|
+
return updated;
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Create a catalog category in the hierarchical category taxonomy.
|
|
2578
|
+
*/
|
|
2579
|
+
async createCategory(input) {
|
|
2580
|
+
return this.engine.taxonomies.createTerm(this.categoriesTaxonomy, input);
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Get all catalog categories.
|
|
2584
|
+
*/
|
|
2585
|
+
async getCategories(options) {
|
|
2586
|
+
return this.engine.taxonomies.getTerms(this.categoriesTaxonomy, options);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Get full hierarchical catalog category tree.
|
|
2590
|
+
*/
|
|
2591
|
+
async getCategoryTree() {
|
|
2592
|
+
return this.engine.taxonomies.getTermTree(this.categoriesTaxonomy);
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Assign category IDs to a product.
|
|
2596
|
+
*/
|
|
2597
|
+
async assignProductCategory(productId, categoryIds) {
|
|
2598
|
+
const ids = Array.isArray(categoryIds) ? categoryIds : [categoryIds];
|
|
2599
|
+
await this.engine.taxonomies.assignTerms(productId, ids);
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Get assigned categories for a product.
|
|
2603
|
+
*/
|
|
2604
|
+
async getProductCategories(productId) {
|
|
2605
|
+
return this.engine.taxonomies.getContentTerms(productId, this.categoriesTaxonomy);
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Create a promotional coupon / discount code.
|
|
2609
|
+
*/
|
|
2610
|
+
async createDiscount(input, authorId) {
|
|
2611
|
+
const normalizedCode = input.code.trim().toUpperCase();
|
|
2612
|
+
const discountData = {
|
|
2613
|
+
code: normalizedCode,
|
|
2614
|
+
discountType: input.discountType,
|
|
2615
|
+
value: input.value,
|
|
2616
|
+
minOrderAmount: input.minOrderAmount,
|
|
2617
|
+
maxDiscountAmount: input.maxDiscountAmount,
|
|
2618
|
+
maxUses: input.maxUses,
|
|
2619
|
+
usedCount: 0,
|
|
2620
|
+
startDate: input.startDate,
|
|
2621
|
+
endDate: input.endDate,
|
|
2622
|
+
status: input.status ?? "active",
|
|
2623
|
+
appliesToProductIds: input.appliesToProductIds ?? [],
|
|
2624
|
+
appliesToCategoryIds: input.appliesToCategoryIds ?? []
|
|
2625
|
+
};
|
|
2626
|
+
const discount = await this.discountsCollection.create({
|
|
2627
|
+
title: input.title,
|
|
2628
|
+
slug: normalizedCode.toLowerCase(),
|
|
2629
|
+
status: "published",
|
|
2630
|
+
data: discountData
|
|
2631
|
+
}, authorId);
|
|
2632
|
+
await this.engine.hooks.doAction("ecommerce.discount_created", discount);
|
|
2633
|
+
return discount;
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Find a discount code.
|
|
2637
|
+
*/
|
|
2638
|
+
async getDiscountByCode(code) {
|
|
2639
|
+
const normalized = code.trim().toUpperCase();
|
|
2640
|
+
return (await this.discountsCollection.find({ limit: 100 })).items.find((d) => d.data.code?.toUpperCase() === normalized) ?? null;
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* Validate a discount coupon against cart items and order subtotal.
|
|
2644
|
+
*/
|
|
2645
|
+
async validateDiscount(code, cartSubtotal, productIds = []) {
|
|
2646
|
+
const normalized = code.trim().toUpperCase();
|
|
2647
|
+
const discount = await this.getDiscountByCode(normalized);
|
|
2648
|
+
if (!discount) return {
|
|
2649
|
+
valid: false,
|
|
2650
|
+
code: normalized,
|
|
2651
|
+
discountAmount: 0,
|
|
2652
|
+
message: `Discount code '${code}' not found.`
|
|
2653
|
+
};
|
|
2654
|
+
const { data } = discount;
|
|
2655
|
+
if (data.status !== "active") return {
|
|
2656
|
+
valid: false,
|
|
2657
|
+
code: normalized,
|
|
2658
|
+
discountAmount: 0,
|
|
2659
|
+
message: "Discount code is currently inactive."
|
|
2660
|
+
};
|
|
2661
|
+
const now = Date.now();
|
|
2662
|
+
if (data.startDate && new Date(data.startDate).getTime() > now) return {
|
|
2663
|
+
valid: false,
|
|
2664
|
+
code: normalized,
|
|
2665
|
+
discountAmount: 0,
|
|
2666
|
+
message: "Discount code is not yet active."
|
|
2667
|
+
};
|
|
2668
|
+
if (data.endDate && new Date(data.endDate).getTime() < now) return {
|
|
2669
|
+
valid: false,
|
|
2670
|
+
code: normalized,
|
|
2671
|
+
discountAmount: 0,
|
|
2672
|
+
message: "Discount code has expired."
|
|
2673
|
+
};
|
|
2674
|
+
if (data.maxUses !== void 0 && data.usedCount >= data.maxUses) return {
|
|
2675
|
+
valid: false,
|
|
2676
|
+
code: normalized,
|
|
2677
|
+
discountAmount: 0,
|
|
2678
|
+
message: "Discount code usage limit reached."
|
|
2679
|
+
};
|
|
2680
|
+
if (data.minOrderAmount !== void 0 && cartSubtotal < data.minOrderAmount) return {
|
|
2681
|
+
valid: false,
|
|
2682
|
+
code: normalized,
|
|
2683
|
+
discountAmount: 0,
|
|
2684
|
+
message: `Minimum order amount of ${data.minOrderAmount} required for this coupon.`
|
|
2685
|
+
};
|
|
2686
|
+
if (data.appliesToProductIds && data.appliesToProductIds.length > 0 && productIds.length > 0) {
|
|
2687
|
+
if (!productIds.some((id) => data.appliesToProductIds?.includes(id))) return {
|
|
2688
|
+
valid: false,
|
|
2689
|
+
code: normalized,
|
|
2690
|
+
discountAmount: 0,
|
|
2691
|
+
message: "Coupon is not applicable to any products in your cart."
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
let discountAmount = 0;
|
|
2695
|
+
if (data.discountType === "percentage") {
|
|
2696
|
+
discountAmount = cartSubtotal * data.value / 100;
|
|
2697
|
+
if (data.maxDiscountAmount) discountAmount = Math.min(discountAmount, data.maxDiscountAmount);
|
|
2698
|
+
} else if (data.discountType === "fixed_amount") discountAmount = Math.min(data.value, cartSubtotal);
|
|
2699
|
+
else if (data.discountType === "free_shipping") discountAmount = 0;
|
|
2700
|
+
discountAmount = await this.engine.hooks.applyFilters("ecommerce.apply_discount", discountAmount, {
|
|
2701
|
+
discount,
|
|
2702
|
+
cartSubtotal
|
|
2703
|
+
});
|
|
2704
|
+
return {
|
|
2705
|
+
valid: true,
|
|
2706
|
+
code: normalized,
|
|
2707
|
+
discountAmount: Math.round(discountAmount * 100) / 100,
|
|
2708
|
+
discountType: data.discountType,
|
|
2709
|
+
discount
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Calculate cart subtotals, apply discounts, shipping, and taxes.
|
|
2714
|
+
*/
|
|
2715
|
+
async calculateCart(input) {
|
|
2716
|
+
const lineItems = [];
|
|
2717
|
+
let subtotal = 0;
|
|
2718
|
+
const productIds = [];
|
|
2719
|
+
for (const item of input.items) {
|
|
2720
|
+
const product = await this.getProduct(item.productId);
|
|
2721
|
+
if (!product) throw new Error(`[EcommerceService] Product '${item.productId}' not found.`);
|
|
2722
|
+
productIds.push(product.id);
|
|
2723
|
+
let price = product.data.price;
|
|
2724
|
+
let sku = product.data.sku;
|
|
2725
|
+
let title = product.title ?? "Product";
|
|
2726
|
+
let availableStock = product.data.stock;
|
|
2727
|
+
let image = product.data.featuredImage;
|
|
2728
|
+
if (item.variantId && product.data.variants) {
|
|
2729
|
+
const variant = product.data.variants.find((v) => v.id === item.variantId);
|
|
2730
|
+
if (variant) {
|
|
2731
|
+
price = variant.price ?? price;
|
|
2732
|
+
sku = variant.sku ?? sku;
|
|
2733
|
+
title = `${title} (${variant.title})`;
|
|
2734
|
+
availableStock = variant.stock ?? availableStock;
|
|
2735
|
+
image = variant.image ?? image;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
if (this.inventoryManagement && product.data.trackInventory && availableStock < item.quantity) throw new Error(`[EcommerceService] Insufficient stock for '${title}'. Available: ${availableStock}, Requested: ${item.quantity}.`);
|
|
2739
|
+
const itemSubtotal = Math.round(price * item.quantity * 100) / 100;
|
|
2740
|
+
subtotal += itemSubtotal;
|
|
2741
|
+
lineItems.push({
|
|
2742
|
+
productId: product.id,
|
|
2743
|
+
variantId: item.variantId,
|
|
2744
|
+
title,
|
|
2745
|
+
sku,
|
|
2746
|
+
price,
|
|
2747
|
+
quantity: item.quantity,
|
|
2748
|
+
subtotal: itemSubtotal,
|
|
2749
|
+
image
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
subtotal = Math.round(subtotal * 100) / 100;
|
|
2753
|
+
let discountTotal = 0;
|
|
2754
|
+
let discountType;
|
|
2755
|
+
if (input.discountCode) {
|
|
2756
|
+
const validation = await this.validateDiscount(input.discountCode, subtotal, productIds);
|
|
2757
|
+
if (validation.valid) {
|
|
2758
|
+
discountTotal = validation.discountAmount;
|
|
2759
|
+
discountType = validation.discountType;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
let shippingTotal = input.shippingCost ?? this.options.defaultShippingCost ?? 0;
|
|
2763
|
+
if (discountType === "free_shipping") shippingTotal = 0;
|
|
2764
|
+
const taxRate = input.taxRate ?? this.options.defaultTaxRate ?? 0;
|
|
2765
|
+
const taxableAmount = Math.max(0, subtotal - discountTotal);
|
|
2766
|
+
const taxTotal = Math.round(taxableAmount * taxRate * 100) / 100;
|
|
2767
|
+
const total = Math.round((taxableAmount + shippingTotal + taxTotal) * 100) / 100;
|
|
2768
|
+
const result = {
|
|
2769
|
+
items: lineItems,
|
|
2770
|
+
subtotal,
|
|
2771
|
+
discountTotal,
|
|
2772
|
+
discountCode: input.discountCode,
|
|
2773
|
+
shippingTotal,
|
|
2774
|
+
taxTotal,
|
|
2775
|
+
total,
|
|
2776
|
+
currency: this.defaultCurrency
|
|
2777
|
+
};
|
|
2778
|
+
return this.engine.hooks.applyFilters("ecommerce.calculate_totals", result, input);
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Place a new order with cart validation, inventory deduction, and coupon counter updates.
|
|
2782
|
+
*/
|
|
2783
|
+
async createOrder(input, authorId) {
|
|
2784
|
+
const calc = await this.calculateCart({
|
|
2785
|
+
items: input.items,
|
|
2786
|
+
discountCode: input.discountCode,
|
|
2787
|
+
shippingCost: input.shippingCost,
|
|
2788
|
+
taxRate: input.taxRate
|
|
2789
|
+
});
|
|
2790
|
+
const orderNumber = `ORD-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "")}-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
|
|
2791
|
+
if (this.inventoryManagement) for (const item of input.items) await this.adjustStock(item.productId, -item.quantity, item.variantId);
|
|
2792
|
+
if (input.discountCode) {
|
|
2793
|
+
const discount = await this.getDiscountByCode(input.discountCode);
|
|
2794
|
+
if (discount) await this.discountsCollection.update(discount.id, { data: {
|
|
2795
|
+
...discount.data,
|
|
2796
|
+
usedCount: (discount.data.usedCount || 0) + 1
|
|
2797
|
+
} });
|
|
2798
|
+
}
|
|
2799
|
+
const orderData = {
|
|
2800
|
+
orderNumber,
|
|
2801
|
+
customerEmail: input.customerEmail,
|
|
2802
|
+
customerName: input.customerName,
|
|
2803
|
+
status: "pending",
|
|
2804
|
+
currency: calc.currency,
|
|
2805
|
+
items: calc.items,
|
|
2806
|
+
subtotal: calc.subtotal,
|
|
2807
|
+
discountTotal: calc.discountTotal,
|
|
2808
|
+
discountCode: calc.discountCode,
|
|
2809
|
+
shippingTotal: calc.shippingTotal,
|
|
2810
|
+
taxTotal: calc.taxTotal,
|
|
2811
|
+
total: calc.total,
|
|
2812
|
+
shippingAddress: input.shippingAddress,
|
|
2813
|
+
billingAddress: input.billingAddress,
|
|
2814
|
+
paymentMethod: input.paymentMethod,
|
|
2815
|
+
notes: input.notes
|
|
2816
|
+
};
|
|
2817
|
+
const order = await this.ordersCollection.create({
|
|
2818
|
+
title: `Order #${orderNumber}`,
|
|
2819
|
+
slug: orderNumber.toLowerCase(),
|
|
2820
|
+
status: "published",
|
|
2821
|
+
data: orderData
|
|
2822
|
+
}, authorId);
|
|
2823
|
+
await this.engine.hooks.doAction("ecommerce.order_created", order);
|
|
2824
|
+
return order;
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Get order by ID.
|
|
2828
|
+
*/
|
|
2829
|
+
async getOrder(id) {
|
|
2830
|
+
return this.ordersCollection.findById(id);
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Get order by order number.
|
|
2834
|
+
*/
|
|
2835
|
+
async getOrderByNumber(orderNumber) {
|
|
2836
|
+
return (await this.ordersCollection.find({ limit: 100 })).items.find((o) => o.data.orderNumber?.toUpperCase() === orderNumber.toUpperCase()) ?? null;
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Update the status of an order (e.g. pending -> paid -> shipped).
|
|
2840
|
+
*/
|
|
2841
|
+
async updateOrderStatus(id, status, note) {
|
|
2842
|
+
const existing = await this.getOrder(id);
|
|
2843
|
+
if (!existing) return null;
|
|
2844
|
+
const oldStatus = existing.data.status;
|
|
2845
|
+
const updated = await this.ordersCollection.update(id, { data: {
|
|
2846
|
+
...existing.data,
|
|
2847
|
+
status
|
|
2848
|
+
} }, void 0, note);
|
|
2849
|
+
if (updated) await this.engine.hooks.doAction("ecommerce.order_status_changed", {
|
|
2850
|
+
order: updated,
|
|
2851
|
+
oldStatus,
|
|
2852
|
+
newStatus: status,
|
|
2853
|
+
note
|
|
2854
|
+
});
|
|
2855
|
+
return updated;
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
//#endregion
|
|
2859
|
+
//#region src/plugins/ecommerce/routes.ts
|
|
2860
|
+
function json(data, status = 200) {
|
|
2861
|
+
return new Response(JSON.stringify(data), {
|
|
2862
|
+
status,
|
|
2863
|
+
headers: {
|
|
2864
|
+
"Content-Type": "application/json",
|
|
2865
|
+
"Access-Control-Allow-Origin": "*"
|
|
2866
|
+
}
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
function badRequest(message) {
|
|
2870
|
+
return json({ error: message }, 400);
|
|
2871
|
+
}
|
|
2872
|
+
function notFound(message) {
|
|
2873
|
+
return json({ error: message }, 404);
|
|
2874
|
+
}
|
|
2875
|
+
function registerEcommerceRoutes(ctx, service, options = {}) {
|
|
2876
|
+
const prefix = (options.apiPrefix ?? "/api/ecommerce").replace(/\/+$/, "");
|
|
2877
|
+
ctx.registerRoute("GET", `${prefix}/products`, async (_req, { url }) => {
|
|
2878
|
+
try {
|
|
2879
|
+
const categorySlug = url.searchParams.get("category") ?? void 0;
|
|
2880
|
+
const categoryId = url.searchParams.get("categoryId") ?? void 0;
|
|
2881
|
+
const tagSlug = url.searchParams.get("tag") ?? void 0;
|
|
2882
|
+
const brandSlug = url.searchParams.get("brand") ?? void 0;
|
|
2883
|
+
const search = url.searchParams.get("search") ?? void 0;
|
|
2884
|
+
const statusParam = url.searchParams.get("status");
|
|
2885
|
+
const inStock = url.searchParams.get("inStock") === "true";
|
|
2886
|
+
const minPriceStr = url.searchParams.get("minPrice");
|
|
2887
|
+
const maxPriceStr = url.searchParams.get("maxPrice");
|
|
2888
|
+
const minPrice = minPriceStr ? parseFloat(minPriceStr) : void 0;
|
|
2889
|
+
const maxPrice = maxPriceStr ? parseFloat(maxPriceStr) : void 0;
|
|
2890
|
+
const orderBy = url.searchParams.get("orderBy");
|
|
2891
|
+
const orderDirection = url.searchParams.get("orderDirection") ?? "desc";
|
|
2892
|
+
const limitStr = url.searchParams.get("limit");
|
|
2893
|
+
const offsetStr = url.searchParams.get("offset");
|
|
2894
|
+
const limit = limitStr ? parseInt(limitStr, 10) : 20;
|
|
2895
|
+
const offset = offsetStr ? parseInt(offsetStr, 10) : 0;
|
|
2896
|
+
return json(await service.listProducts({
|
|
2897
|
+
categorySlug,
|
|
2898
|
+
categoryId,
|
|
2899
|
+
tagSlug,
|
|
2900
|
+
brandSlug,
|
|
2901
|
+
search,
|
|
2902
|
+
status: statusParam,
|
|
2903
|
+
inStock,
|
|
2904
|
+
minPrice,
|
|
2905
|
+
maxPrice,
|
|
2906
|
+
orderBy,
|
|
2907
|
+
orderDirection,
|
|
2908
|
+
limit,
|
|
2909
|
+
offset
|
|
2910
|
+
}));
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2915
|
+
ctx.registerRoute("GET", `${prefix}/products/:id`, async (_req, { params, url }) => {
|
|
2916
|
+
const id = params.id;
|
|
2917
|
+
const by = url.searchParams.get("by");
|
|
2918
|
+
let product = null;
|
|
2919
|
+
if (by === "slug") product = await service.getProductBySlug(id);
|
|
2920
|
+
else {
|
|
2921
|
+
product = await service.getProduct(id);
|
|
2922
|
+
if (!product) product = await service.getProductBySlug(id);
|
|
2923
|
+
}
|
|
2924
|
+
if (!product) return notFound(`Product '${id}' not found`);
|
|
2925
|
+
return json(product);
|
|
2926
|
+
});
|
|
2927
|
+
ctx.registerRoute("POST", `${prefix}/products`, async (req) => {
|
|
2928
|
+
try {
|
|
2929
|
+
const body = await req.json();
|
|
2930
|
+
if (!body.title) return badRequest("Product 'title' is required.");
|
|
2931
|
+
if (body.price === void 0 || body.price < 0) return badRequest("Valid product 'price' is required.");
|
|
2932
|
+
return json(await service.createProduct(body), 201);
|
|
2933
|
+
} catch (err) {
|
|
2934
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2935
|
+
}
|
|
2936
|
+
});
|
|
2937
|
+
ctx.registerRoute("PUT", `${prefix}/products/:id`, async (req, { params }) => {
|
|
2938
|
+
try {
|
|
2939
|
+
const body = await req.json();
|
|
2940
|
+
const updated = await service.updateProduct(params.id, body);
|
|
2941
|
+
if (!updated) return notFound(`Product '${params.id}' not found.`);
|
|
2942
|
+
return json(updated);
|
|
2943
|
+
} catch (err) {
|
|
2944
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2945
|
+
}
|
|
2946
|
+
});
|
|
2947
|
+
ctx.registerRoute("DELETE", `${prefix}/products/:id`, async (_req, { params }) => {
|
|
2948
|
+
if (!await service.deleteProduct(params.id)) return notFound(`Product '${params.id}' not found.`);
|
|
2949
|
+
return json({
|
|
2950
|
+
success: true,
|
|
2951
|
+
id: params.id
|
|
2952
|
+
});
|
|
2953
|
+
});
|
|
2954
|
+
ctx.registerRoute("POST", `${prefix}/products/:id/images`, async (req, { params }) => {
|
|
2955
|
+
try {
|
|
2956
|
+
const body = await req.json();
|
|
2957
|
+
if (!body.filename || !body.mimeType) return badRequest("'filename' and 'mimeType' are required.");
|
|
2958
|
+
return json(await service.uploadProductImage(params.id, {
|
|
2959
|
+
filename: body.filename,
|
|
2960
|
+
mimeType: body.mimeType,
|
|
2961
|
+
sizeBytes: body.sizeBytes ?? 0,
|
|
2962
|
+
url: body.url,
|
|
2963
|
+
altText: body.altText,
|
|
2964
|
+
caption: body.caption,
|
|
2965
|
+
width: body.width,
|
|
2966
|
+
height: body.height,
|
|
2967
|
+
isFeatured: body.isFeatured
|
|
2968
|
+
}), 201);
|
|
2969
|
+
} catch (err) {
|
|
2970
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2971
|
+
}
|
|
2972
|
+
});
|
|
2973
|
+
ctx.registerRoute("GET", `${prefix}/categories`, async (_req, { url }) => {
|
|
2974
|
+
try {
|
|
2975
|
+
if (url.searchParams.get("tree") === "true") return json(await service.getCategoryTree());
|
|
2976
|
+
const parentId = url.searchParams.get("parentId");
|
|
2977
|
+
return json(await service.getCategories({ parentId: parentId === "null" ? null : parentId ?? void 0 }));
|
|
2978
|
+
} catch (err) {
|
|
2979
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2980
|
+
}
|
|
2981
|
+
});
|
|
2982
|
+
ctx.registerRoute("POST", `${prefix}/categories`, async (req) => {
|
|
2983
|
+
try {
|
|
2984
|
+
const body = await req.json();
|
|
2985
|
+
if (!body.name) return badRequest("Category 'name' is required.");
|
|
2986
|
+
return json(await service.createCategory(body), 201);
|
|
2987
|
+
} catch (err) {
|
|
2988
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
2989
|
+
}
|
|
2990
|
+
});
|
|
2991
|
+
if (options.enableDiscounts !== false) {
|
|
2992
|
+
ctx.registerRoute("POST", `${prefix}/discounts`, async (req) => {
|
|
2993
|
+
try {
|
|
2994
|
+
const body = await req.json();
|
|
2995
|
+
if (!body.title || !body.code) return badRequest("'title' and 'code' are required.");
|
|
2996
|
+
if (body.value === void 0 || body.value < 0) return badRequest("Valid discount 'value' is required.");
|
|
2997
|
+
return json(await service.createDiscount(body), 201);
|
|
2998
|
+
} catch (err) {
|
|
2999
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
3000
|
+
}
|
|
3001
|
+
});
|
|
3002
|
+
ctx.registerRoute("POST", `${prefix}/discounts/validate`, async (req) => {
|
|
3003
|
+
try {
|
|
3004
|
+
const body = await req.json();
|
|
3005
|
+
if (!body.code) return badRequest("Discount 'code' is required.");
|
|
3006
|
+
const subtotal = Number(body.subtotal ?? 0);
|
|
3007
|
+
const productIds = Array.isArray(body.productIds) ? body.productIds : [];
|
|
3008
|
+
return json(await service.validateDiscount(body.code, subtotal, productIds));
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
3011
|
+
}
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
ctx.registerRoute("POST", `${prefix}/cart/calculate`, async (req) => {
|
|
3015
|
+
try {
|
|
3016
|
+
const body = await req.json();
|
|
3017
|
+
if (!body.items || !Array.isArray(body.items) || body.items.length === 0) return badRequest("'items' array is required and must not be empty.");
|
|
3018
|
+
return json(await service.calculateCart(body));
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
if (options.enableOrders !== false) {
|
|
3024
|
+
ctx.registerRoute("POST", `${prefix}/orders`, async (req) => {
|
|
3025
|
+
try {
|
|
3026
|
+
const body = await req.json();
|
|
3027
|
+
if (!body.customerEmail) return badRequest("'customerEmail' is required.");
|
|
3028
|
+
if (!body.items || !Array.isArray(body.items) || body.items.length === 0) return badRequest("'items' array is required.");
|
|
3029
|
+
return json(await service.createOrder(body), 201);
|
|
3030
|
+
} catch (err) {
|
|
3031
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
ctx.registerRoute("GET", `${prefix}/orders/:id`, async (_req, { params, url }) => {
|
|
3035
|
+
const id = params.id;
|
|
3036
|
+
const by = url.searchParams.get("by");
|
|
3037
|
+
let order = null;
|
|
3038
|
+
if (by === "number") order = await service.getOrderByNumber(id);
|
|
3039
|
+
else {
|
|
3040
|
+
order = await service.getOrder(id);
|
|
3041
|
+
if (!order) order = await service.getOrderByNumber(id);
|
|
3042
|
+
}
|
|
3043
|
+
if (!order) return notFound(`Order '${id}' not found.`);
|
|
3044
|
+
return json(order);
|
|
3045
|
+
});
|
|
3046
|
+
ctx.registerRoute("PATCH", `${prefix}/orders/:id/status`, async (req, { params }) => {
|
|
3047
|
+
try {
|
|
3048
|
+
const body = await req.json();
|
|
3049
|
+
if (!body.status) return badRequest("New 'status' is required.");
|
|
3050
|
+
const updated = await service.updateOrderStatus(params.id, body.status, body.note);
|
|
3051
|
+
if (!updated) return notFound(`Order '${params.id}' not found.`);
|
|
3052
|
+
return json(updated);
|
|
3053
|
+
} catch (err) {
|
|
3054
|
+
return badRequest(err instanceof Error ? err.message : String(err));
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
//#endregion
|
|
3060
|
+
//#region src/plugins/ecommerce/client.ts
|
|
3061
|
+
var EcommerceClient = class {
|
|
3062
|
+
client;
|
|
3063
|
+
options;
|
|
3064
|
+
service;
|
|
3065
|
+
prefix;
|
|
3066
|
+
constructor(client, options = {}) {
|
|
3067
|
+
this.client = client;
|
|
3068
|
+
this.options = options;
|
|
3069
|
+
this.prefix = (options.apiPrefix ?? "/api/ecommerce").replace(/\/+$/, "");
|
|
3070
|
+
const engine = client.getEngine();
|
|
3071
|
+
if (engine) this.service = new EcommerceService(engine, options);
|
|
3072
|
+
}
|
|
3073
|
+
products = {
|
|
3074
|
+
find: async (query = {}) => {
|
|
3075
|
+
if (this.service) return this.service.listProducts(query);
|
|
3076
|
+
const params = new URLSearchParams();
|
|
3077
|
+
if (query.categorySlug) params.set("category", query.categorySlug);
|
|
3078
|
+
if (query.categoryId) params.set("categoryId", query.categoryId);
|
|
3079
|
+
if (query.tagSlug) params.set("tag", query.tagSlug);
|
|
3080
|
+
if (query.brandSlug) params.set("brand", query.brandSlug);
|
|
3081
|
+
if (query.search) params.set("search", query.search);
|
|
3082
|
+
if (query.inStock) params.set("inStock", "true");
|
|
3083
|
+
if (query.minPrice !== void 0) params.set("minPrice", String(query.minPrice));
|
|
3084
|
+
if (query.maxPrice !== void 0) params.set("maxPrice", String(query.maxPrice));
|
|
3085
|
+
if (query.orderBy) params.set("orderBy", query.orderBy);
|
|
3086
|
+
if (query.orderDirection) params.set("orderDirection", query.orderDirection);
|
|
3087
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
3088
|
+
if (query.offset !== void 0) params.set("offset", String(query.offset));
|
|
3089
|
+
const q = params.toString() ? `?${params.toString()}` : "";
|
|
3090
|
+
return this.client.request(`${this.prefix}/products${q}`);
|
|
3091
|
+
},
|
|
3092
|
+
get: async (idOrSlug, by) => {
|
|
3093
|
+
if (this.service) return by === "slug" ? this.service.getProductBySlug(idOrSlug) : this.service.getProduct(idOrSlug);
|
|
3094
|
+
const query = by ? `?by=${by}` : "";
|
|
3095
|
+
return this.client.request(`${this.prefix}/products/${encodeURIComponent(idOrSlug)}${query}`);
|
|
3096
|
+
},
|
|
3097
|
+
create: async (data) => {
|
|
3098
|
+
if (this.service) return this.service.createProduct(data);
|
|
3099
|
+
return this.client.request(`${this.prefix}/products`, {
|
|
3100
|
+
method: "POST",
|
|
3101
|
+
body: JSON.stringify(data)
|
|
3102
|
+
});
|
|
3103
|
+
},
|
|
3104
|
+
update: async (id, data) => {
|
|
3105
|
+
if (this.service) return this.service.updateProduct(id, data);
|
|
3106
|
+
return this.client.request(`${this.prefix}/products/${encodeURIComponent(id)}`, {
|
|
3107
|
+
method: "PUT",
|
|
3108
|
+
body: JSON.stringify(data)
|
|
3109
|
+
});
|
|
3110
|
+
},
|
|
3111
|
+
delete: async (id) => {
|
|
3112
|
+
if (this.service) return this.service.deleteProduct(id);
|
|
3113
|
+
const res = await this.client.request(`${this.prefix}/products/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
3114
|
+
return Boolean(res?.success);
|
|
3115
|
+
},
|
|
3116
|
+
uploadImage: async (productId, file) => {
|
|
3117
|
+
if (this.service) return this.service.uploadProductImage(productId, file);
|
|
3118
|
+
return this.client.request(`${this.prefix}/products/${encodeURIComponent(productId)}/images`, {
|
|
3119
|
+
method: "POST",
|
|
3120
|
+
body: JSON.stringify(file)
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
};
|
|
3124
|
+
categories = {
|
|
3125
|
+
list: async (options) => {
|
|
3126
|
+
if (this.service) return this.service.getCategories(options);
|
|
3127
|
+
const q = options?.parentId !== void 0 ? `?parentId=${options.parentId}` : "";
|
|
3128
|
+
return this.client.request(`${this.prefix}/categories${q}`);
|
|
3129
|
+
},
|
|
3130
|
+
tree: async () => {
|
|
3131
|
+
if (this.service) return this.service.getCategoryTree();
|
|
3132
|
+
return this.client.request(`${this.prefix}/categories?tree=true`);
|
|
3133
|
+
},
|
|
3134
|
+
create: async (input) => {
|
|
3135
|
+
if (this.service) return this.service.createCategory(input);
|
|
3136
|
+
return this.client.request(`${this.prefix}/categories`, {
|
|
3137
|
+
method: "POST",
|
|
3138
|
+
body: JSON.stringify(input)
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
discounts = {
|
|
3143
|
+
validate: async (code, subtotal, productIds) => {
|
|
3144
|
+
if (this.service) return this.service.validateDiscount(code, subtotal, productIds);
|
|
3145
|
+
return this.client.request(`${this.prefix}/discounts/validate`, {
|
|
3146
|
+
method: "POST",
|
|
3147
|
+
body: JSON.stringify({
|
|
3148
|
+
code,
|
|
3149
|
+
subtotal,
|
|
3150
|
+
productIds
|
|
3151
|
+
})
|
|
3152
|
+
});
|
|
3153
|
+
},
|
|
3154
|
+
create: async (input) => {
|
|
3155
|
+
if (this.service) return this.service.createDiscount(input);
|
|
3156
|
+
return this.client.request(`${this.prefix}/discounts`, {
|
|
3157
|
+
method: "POST",
|
|
3158
|
+
body: JSON.stringify(input)
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
};
|
|
3162
|
+
cart = { calculate: async (input) => {
|
|
3163
|
+
if (this.service) return this.service.calculateCart(input);
|
|
3164
|
+
return this.client.request(`${this.prefix}/cart/calculate`, {
|
|
3165
|
+
method: "POST",
|
|
3166
|
+
body: JSON.stringify(input)
|
|
3167
|
+
});
|
|
3168
|
+
} };
|
|
3169
|
+
orders = {
|
|
3170
|
+
create: async (input) => {
|
|
3171
|
+
if (this.service) return this.service.createOrder(input);
|
|
3172
|
+
return this.client.request(`${this.prefix}/orders`, {
|
|
3173
|
+
method: "POST",
|
|
3174
|
+
body: JSON.stringify(input)
|
|
3175
|
+
});
|
|
3176
|
+
},
|
|
3177
|
+
get: async (idOrNumber, by) => {
|
|
3178
|
+
if (this.service) return by === "number" ? this.service.getOrderByNumber(idOrNumber) : this.service.getOrder(idOrNumber);
|
|
3179
|
+
const q = by ? `?by=${by}` : "";
|
|
3180
|
+
return this.client.request(`${this.prefix}/orders/${encodeURIComponent(idOrNumber)}${q}`);
|
|
3181
|
+
},
|
|
3182
|
+
updateStatus: async (id, status, note) => {
|
|
3183
|
+
if (this.service) return this.service.updateOrderStatus(id, status, note);
|
|
3184
|
+
return this.client.request(`${this.prefix}/orders/${encodeURIComponent(id)}/status`, {
|
|
3185
|
+
method: "PATCH",
|
|
3186
|
+
body: JSON.stringify({
|
|
3187
|
+
status,
|
|
3188
|
+
note
|
|
3189
|
+
})
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
};
|
|
3194
|
+
/**
|
|
3195
|
+
* Get or create an EcommerceClient adapter for a CMSClient.
|
|
3196
|
+
*/
|
|
3197
|
+
function getEcommerceClient(client, options) {
|
|
3198
|
+
return new EcommerceClient(client, options);
|
|
3199
|
+
}
|
|
3200
|
+
//#endregion
|
|
3201
|
+
//#region src/plugins/ecommerce/index.ts
|
|
3202
|
+
/**
|
|
3203
|
+
* @azlib/cms - Built-in E-commerce Plugin
|
|
3204
|
+
*/
|
|
3205
|
+
/**
|
|
3206
|
+
* Built-in E-commerce plugin factory for @azlib/cms.
|
|
3207
|
+
* Equips the CMS engine with product catalogs, hierarchical categories,
|
|
3208
|
+
* image uploading, discount coupons, cart calculation, and order tracking.
|
|
3209
|
+
*/
|
|
3210
|
+
const ecommercePlugin = definePlugin((options) => {
|
|
3211
|
+
const opts = options || {};
|
|
3212
|
+
const collections = [createProductCollection(opts)];
|
|
3213
|
+
if (opts.enableDiscounts !== false) collections.push(createDiscountCollection(opts));
|
|
3214
|
+
if (opts.enableOrders !== false) collections.push(createOrderCollection(opts));
|
|
3215
|
+
return {
|
|
3216
|
+
name: "ecommerce",
|
|
3217
|
+
version: "1.0.0",
|
|
3218
|
+
description: "Built-in E-commerce shopping, products, catalogs, discounts, and orders plugin",
|
|
3219
|
+
collections,
|
|
3220
|
+
taxonomies: createEcommerceTaxonomies(opts),
|
|
3221
|
+
setup(ctx) {
|
|
3222
|
+
const service = new EcommerceService(ctx.engine, opts);
|
|
3223
|
+
ctx.engine.__ecommerceService = service;
|
|
3224
|
+
registerEcommerceRoutes(ctx, service, opts);
|
|
3225
|
+
}
|
|
3226
|
+
};
|
|
3227
|
+
});
|
|
3228
|
+
/**
|
|
3229
|
+
* Retrieve the active EcommerceService instance associated with a CMSEngine.
|
|
3230
|
+
*/
|
|
3231
|
+
function getEcommerceService(engine, options) {
|
|
3232
|
+
if (engine.__ecommerceService) return engine.__ecommerceService;
|
|
3233
|
+
const service = new EcommerceService(engine, options);
|
|
3234
|
+
engine.__ecommerceService = service;
|
|
3235
|
+
return service;
|
|
3236
|
+
}
|
|
3237
|
+
//#endregion
|
|
1779
3238
|
exports.CMSClient = CMSClient;
|
|
1780
3239
|
exports.CMSEngine = CMSEngine;
|
|
1781
3240
|
exports.CMSRouter = CMSRouter;
|
|
1782
3241
|
exports.ContentLifecycle = ContentLifecycle;
|
|
1783
3242
|
exports.DEFAULT_COLLECTIONS = DEFAULT_COLLECTIONS;
|
|
1784
3243
|
exports.DEFAULT_ROLE_CAPABILITIES = DEFAULT_ROLE_CAPABILITIES;
|
|
3244
|
+
exports.EcommerceClient = EcommerceClient;
|
|
3245
|
+
exports.EcommerceService = EcommerceService;
|
|
1785
3246
|
exports.HooksManager = HooksManager;
|
|
1786
3247
|
exports.MediaManager = MediaManager;
|
|
1787
3248
|
exports.MemoryStorageAdapter = MemoryStorageAdapter;
|
|
@@ -1794,9 +3255,17 @@ exports.collection = collection;
|
|
|
1794
3255
|
exports.createCMSEngine = createCMSEngine;
|
|
1795
3256
|
exports.createCMSRouter = createCMSRouter;
|
|
1796
3257
|
exports.createCmsClient = createCmsClient;
|
|
3258
|
+
exports.createDiscountCollection = createDiscountCollection;
|
|
3259
|
+
exports.createEcommerceTaxonomies = createEcommerceTaxonomies;
|
|
3260
|
+
exports.createOrderCollection = createOrderCollection;
|
|
3261
|
+
exports.createProductCollection = createProductCollection;
|
|
1797
3262
|
exports.defaultHooks = defaultHooks;
|
|
1798
3263
|
exports.defineConfig = defineConfig;
|
|
3264
|
+
exports.definePlugin = definePlugin;
|
|
3265
|
+
exports.ecommercePlugin = ecommercePlugin;
|
|
1799
3266
|
exports.fields = fields;
|
|
3267
|
+
exports.getEcommerceClient = getEcommerceClient;
|
|
3268
|
+
exports.getEcommerceService = getEcommerceService;
|
|
1800
3269
|
exports.normalizeConfig = normalizeConfig;
|
|
1801
3270
|
exports.resolveUniqueSlug = resolveUniqueSlug;
|
|
1802
3271
|
exports.slugify = slugify;
|