@commercelayer/agent-cli 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -19
- package/dist/api/client.js +39 -1
- package/dist/api/stub/management.js +169 -29
- package/dist/api/stub/shopping.js +5 -6
- package/dist/api/stub/theme.js +32 -3
- package/dist/api/stub/ucp.js +4 -1
- package/dist/bin.js +31 -1
- package/dist/commands.js +160 -27
- package/dist/files.js +21 -0
- package/dist/links.js +118 -0
- package/dist/promotions.js +59 -30
- package/dist/store.js +3 -29
- package/dist/storefront.js +6 -3
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -4,13 +4,15 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { createApi } from './api/client.js';
|
|
7
|
-
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
|
|
7
|
+
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
|
|
8
8
|
import { CliError, fail, progress } from './output.js';
|
|
9
9
|
import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
10
10
|
import { storefrontFiles } from './storefront.js';
|
|
11
11
|
import { imageNotes, planAssets, uploadAssets } from './assets.js';
|
|
12
12
|
import { pull } from './catalog.js';
|
|
13
|
-
import {
|
|
13
|
+
import { checkLink } from './links.js';
|
|
14
|
+
import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
|
|
15
|
+
import YAML from 'yaml';
|
|
14
16
|
import { storefrontCheck } from './check.js';
|
|
15
17
|
import { buy } from './ucp/buy.js';
|
|
16
18
|
import { check } from './ucp/check.js';
|
|
@@ -19,13 +21,26 @@ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
|
19
21
|
/** The package version, read from package.json so a release bumps it in one place. */
|
|
20
22
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
21
23
|
export const HELP = {
|
|
22
|
-
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials. Reuses the store if store.yml already exists.',
|
|
23
|
-
plan: 'Show what apply would create, update or delete: markets, products,
|
|
24
|
-
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products,
|
|
24
|
+
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
|
|
25
|
+
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
|
|
26
|
+
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the theme (its settings, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
25
27
|
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
26
28
|
'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
|
|
27
29
|
'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
28
30
|
'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
|
|
31
|
+
'promotions list': 'The store\'s promotions, live in Commerce Layer (not in store files): id, name, status (active, scheduled, expired, disabled, used_up), rules, actions, uses, starts, ends; managed: false marks one made in the dashboard (its id is Commerce Layer\'s), which can be enabled, disabled or deleted but not updated. warnings flags more than 10 active promotion actions without a coupon (Commerce Layer applies 10).',
|
|
32
|
+
'promotions show': 'One promotion by id, as list shows it: c11r promotions show <id>.',
|
|
33
|
+
'promotions create': "Create a promotion from a spec in YAML or JSON, on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>: { id, name, market, starts, ends, limit, priority, exclusive, enabled, rules: [{ coupon | order_amount: { min } | skus | category }], actions: [{ percentage | fixed_amount | free_shipping | fixed_price | free_gift | buy_x_pay_y }] } or flex: { rules } in place of rules and actions. id is the handle, unique. Amounts are in the market currency, in major units. A category becomes the skus of its products in store.yml now: after adding products to it, run update again. Errors: invalid_promotion (the message starts with the path in the spec), promotion_exists, flex_unavailable (no Rules Engine: nothing is created). Check it with orders test --code.",
|
|
34
|
+
'promotions update': 'Replace a promotion with a whole spec, same as create, on stdin or -f <file>: c11r promotions update <id>. The id stays the same. What show prints can be edited and sent back. A promotion made in the dashboard (managed: false) cannot be updated: promotion_not_managed; enable, disable and delete work on it.',
|
|
35
|
+
'promotions enable': 'Turn a promotion on: c11r promotions enable <id>.',
|
|
36
|
+
'promotions disable': 'Turn a promotion off without deleting it: c11r promotions disable <id>.',
|
|
37
|
+
'promotions delete': 'Delete a promotion from Commerce Layer: c11r promotions delete <id>. Ask the human first; to pause one, disable it.',
|
|
38
|
+
'links list': 'The store\'s links, live in Commerce Layer (not in store files): shareable URLs that open a hosted microstore for one sku, a set of skus or a category, or the hosted checkout for an order. For each: id, name, url, status (pending, active, expired, disabled), for (sku | skus | category with its skus | order), markets, starts, ends, params; managed: false marks one made in the dashboard (its id is Commerce Layer\'s).',
|
|
39
|
+
'links show': 'One link by id, as list shows it: c11r links show <id>.',
|
|
40
|
+
'links create': "Create a link from a spec in YAML or JSON, on stdin (c11r links create <<'EOF' ... EOF) or with -f <file>: { id, name, for: { sku } | { skus: [...] } | { category: \"A > B\" } | { order: <number> | cart_<id> | chk_<id> }, market (a code or a list; default every market; none for an order), starts (default now), ends (default a month after starts), params }. id is the handle, unique. A category becomes the skus of its products in store.yml now (at most 100 skus). An order link opens the checkout of a draft or pending order: its number, or the id of a UCP cart or checkout. One Commerce Layer link per market: urls has each, url the first market's. A link cannot be changed: delete it and create it again. Errors: invalid_link (the message starts with the path in the spec), link_exists.",
|
|
41
|
+
'links enable': 'Turn a link on: c11r links enable <id>.',
|
|
42
|
+
'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
|
|
43
|
+
'links delete': 'Delete a link from Commerce Layer: c11r links delete <id>. Ask the human first; to pause one, disable it.',
|
|
29
44
|
'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
|
|
30
45
|
'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
|
|
31
46
|
'orders show': 'Show one order: c11r orders show <id or number>.',
|
|
@@ -54,7 +69,8 @@ export async function init(values) {
|
|
|
54
69
|
const existing = readCredentials();
|
|
55
70
|
if (existing && fs.existsSync(paths.store)) {
|
|
56
71
|
const s = await createApi(existing).getStore();
|
|
57
|
-
|
|
72
|
+
const notes = writeAgentNotes();
|
|
73
|
+
return { status: 'reused', org: s.org, claim_url: s.claim_url, expires_at: s.expires_at, preview_url: s.preview_url, files: notes.length ? notes : undefined };
|
|
58
74
|
}
|
|
59
75
|
// The business name becomes the organization's name; the platform derives the
|
|
60
76
|
// org slug (and so the store hostname) from it. The folder name is the fallback.
|
|
@@ -69,6 +85,7 @@ export async function init(values) {
|
|
|
69
85
|
const files = [paths.store, paths.credentials];
|
|
70
86
|
if (ignoreCredentials())
|
|
71
87
|
files.push('.gitignore');
|
|
88
|
+
files.push(...writeAgentNotes());
|
|
72
89
|
return {
|
|
73
90
|
status: 'created',
|
|
74
91
|
org: sandbox.org,
|
|
@@ -80,16 +97,6 @@ export async function init(values) {
|
|
|
80
97
|
next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv, the theme under theme:, images in .commercelayer/img/), then run c11r plan and c11r apply: the website is live at preview_url.',
|
|
81
98
|
};
|
|
82
99
|
}
|
|
83
|
-
/** Warnings plan and apply give about promotions: the active limit, and an API without them. */
|
|
84
|
-
function promotionWarnings(state, desired) {
|
|
85
|
-
const out = [];
|
|
86
|
-
if (desired.promotions.length && state.promotions === undefined)
|
|
87
|
-
out.push('This API does not apply promotions yet: apply will refuse while store.yml has any.');
|
|
88
|
-
const active = activeWithoutCoupon(desired.promotions);
|
|
89
|
-
if (active.count > ACTIVE_LIMIT)
|
|
90
|
-
out.push(`${active.count} promotions without a coupon are active (one per action, in ${active.ids.join(', ')}): Commerce Layer applies at most ${ACTIVE_LIMIT}, the ones with the lowest priority number. Give the rest a coupon, end dates, or enabled: false.`);
|
|
91
|
-
return out.length ? out : undefined;
|
|
92
|
-
}
|
|
93
100
|
export async function plan() {
|
|
94
101
|
const api = createApi(requireCredentials());
|
|
95
102
|
const desired = loadStore();
|
|
@@ -101,7 +108,6 @@ export async function plan() {
|
|
|
101
108
|
changes,
|
|
102
109
|
deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
|
|
103
110
|
...imageNotes(assets),
|
|
104
|
-
warnings: promotionWarnings(state, desired),
|
|
105
111
|
next: assets.missing.length ? 'Put the missing images in .commercelayer/img/ (img/<file> in store.yml is .commercelayer/img/<file>), or fix the references: apply refuses until they are there.' : undefined,
|
|
106
112
|
};
|
|
107
113
|
}
|
|
@@ -160,9 +166,6 @@ export async function apply(flags) {
|
|
|
160
166
|
if (assets.missing.length) {
|
|
161
167
|
fail('missing_images', `The store references images that are not in .commercelayer/img/: ${assets.missing.join('; ')}.`, 'Put the files there (img/<file> in store.yml is .commercelayer/img/<file>), or fix or remove the references, then apply again.');
|
|
162
168
|
}
|
|
163
|
-
if (desired.promotions.length && state.promotions === undefined) {
|
|
164
|
-
fail('promotions_unsupported', 'This API does not apply promotions yet.', 'Remove promotions: from store.yml to apply the rest, or update c11r (npx @commercelayer/agent-cli@latest) and try again.');
|
|
165
|
-
}
|
|
166
169
|
// Images first, so the theme and products never point at a file the store host does not have yet.
|
|
167
170
|
const uploaded = assets.supported ? await uploadAssets(api, assets) : 0;
|
|
168
171
|
const body = {
|
|
@@ -171,7 +174,6 @@ export async function apply(flags) {
|
|
|
171
174
|
name: desired.name,
|
|
172
175
|
policies: desired.policies,
|
|
173
176
|
theme: desired.theme ?? {},
|
|
174
|
-
promotions: desired.promotions,
|
|
175
177
|
...(assets.supported && { assets: assets.keep }),
|
|
176
178
|
...(flags.has('--allow-delete') && { allow_delete: true }),
|
|
177
179
|
...(flags.has('--full') && { full: true }),
|
|
@@ -181,10 +183,9 @@ export async function apply(flags) {
|
|
|
181
183
|
const done = job ? await follow(api, started) : started;
|
|
182
184
|
if (job && done.apply?.id === job.id && done.apply.status === 'failed') {
|
|
183
185
|
const errors = done.apply.errors;
|
|
184
|
-
const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', `Fix ${promos ? 'them' : 'those products'} in store.yml${promos ? '' : ' or the catalog source'} and run \`c11r apply\` again; the rest of the store is unchanged.`);
|
|
186
|
+
const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.message)).join('; ');
|
|
187
|
+
const what = errors.length === 1 ? '1 product was refused' : `${errors.length} products were refused`;
|
|
188
|
+
fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', 'Fix those products in store.yml or the catalog source and run `c11r apply` again; the rest of the store is unchanged.');
|
|
188
189
|
}
|
|
189
190
|
return {
|
|
190
191
|
status: 'applied',
|
|
@@ -192,8 +193,6 @@ export async function apply(flags) {
|
|
|
192
193
|
preview_url: done.preview_url,
|
|
193
194
|
markets: desired.markets.length,
|
|
194
195
|
products: desired.products.length,
|
|
195
|
-
promotions: desired.promotions.length || undefined,
|
|
196
|
-
warnings: promotionWarnings(state, desired),
|
|
197
196
|
payment_gateway: 'sandbox (test payments only)',
|
|
198
197
|
images_uploaded: uploaded || undefined,
|
|
199
198
|
unused_images: assets.unused.length ? assets.unused : undefined,
|
|
@@ -355,3 +354,137 @@ export async function claim() {
|
|
|
355
354
|
next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
|
|
356
355
|
};
|
|
357
356
|
}
|
|
357
|
+
// ---------- promotions: live settings in Commerce Layer, not store files
|
|
358
|
+
/** What list and show print beyond the spec: dropped when it is sent back. */
|
|
359
|
+
const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
|
|
360
|
+
/**
|
|
361
|
+
* A spec from -f <file> or stdin, YAML or JSON, for promotions and links. What show prints
|
|
362
|
+
* can be edited and sent back: the read-only fields are dropped.
|
|
363
|
+
*/
|
|
364
|
+
async function readInput(values, what) {
|
|
365
|
+
const usage = `Give the ${what} as YAML or JSON on stdin (c11r ${what}s create <<'EOF' ... EOF) or with -f <file>.`;
|
|
366
|
+
let text = '';
|
|
367
|
+
if (values.file) {
|
|
368
|
+
if (!fs.existsSync(values.file))
|
|
369
|
+
fail('missing_argument', `There is no file ${values.file}.`, `Give the ${what} as YAML or JSON with -f <file>, or on stdin.`);
|
|
370
|
+
text = fs.readFileSync(values.file, 'utf8');
|
|
371
|
+
}
|
|
372
|
+
else if (!process.stdin.isTTY) {
|
|
373
|
+
// A stream read: readFileSync(0) fails with EAGAIN on a pipe whose writer is still running.
|
|
374
|
+
const chunks = [];
|
|
375
|
+
for await (const chunk of process.stdin)
|
|
376
|
+
chunks.push(chunk);
|
|
377
|
+
text = Buffer.concat(chunks).toString('utf8');
|
|
378
|
+
}
|
|
379
|
+
else
|
|
380
|
+
fail('missing_argument', `No ${what} given.`, usage);
|
|
381
|
+
if (!text.trim())
|
|
382
|
+
fail('missing_argument', `No ${what} given: the input is empty.`, usage);
|
|
383
|
+
let raw;
|
|
384
|
+
try {
|
|
385
|
+
raw = YAML.parse(text);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
fail(`invalid_${what}`, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
|
|
389
|
+
}
|
|
390
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
391
|
+
for (const k of READ_ONLY)
|
|
392
|
+
delete raw[k];
|
|
393
|
+
return raw;
|
|
394
|
+
}
|
|
395
|
+
/** A promotion spec, validated against store.yml's markets and products. */
|
|
396
|
+
async function readSpec(values) {
|
|
397
|
+
const raw = await readInput(values, 'promotion');
|
|
398
|
+
const store = loadStore();
|
|
399
|
+
return checkPromotion(raw, store.markets, store.products);
|
|
400
|
+
}
|
|
401
|
+
/** Warn when more promotion actions without a coupon are live than Commerce Layer applies. */
|
|
402
|
+
async function activeWarning(api) {
|
|
403
|
+
const { promotions } = await api.listPromotions();
|
|
404
|
+
const live = promotions.filter((p) => p.status === 'active' || p.status === 'scheduled');
|
|
405
|
+
const active = activeWithoutCoupon(live);
|
|
406
|
+
return active.count > ACTIVE_LIMIT ? [`${active.count} promotions without a coupon are active or scheduled (one per action, in ${active.ids.join(', ')}): Commerce Layer applies at most ${ACTIVE_LIMIT}, the ones with the lowest priority number. Give the rest a coupon, an end date, or disable them.`] : undefined;
|
|
407
|
+
}
|
|
408
|
+
const checkNext = (p) => p.rules?.coupons?.length ? `Check it: c11r orders test --code ${p.rules.coupons[0]}, and read discounts.` : p.flex ? undefined : 'Check it: c11r orders test with an order that matches the rules (--sku, --quantity), and read discounts.';
|
|
409
|
+
export async function promotionsList() {
|
|
410
|
+
const api = createApi(requireCredentials());
|
|
411
|
+
const { promotions } = await api.listPromotions();
|
|
412
|
+
return { count: promotions.length, promotions, warnings: await activeWarning(api) };
|
|
413
|
+
}
|
|
414
|
+
export async function promotionsShow(id) {
|
|
415
|
+
if (!id)
|
|
416
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions show <id>`; `c11r promotions list` has the ids.');
|
|
417
|
+
return createApi(requireCredentials()).getPromotion(id);
|
|
418
|
+
}
|
|
419
|
+
export async function promotionsCreate(values) {
|
|
420
|
+
const api = createApi(requireCredentials());
|
|
421
|
+
const spec = await readSpec(values);
|
|
422
|
+
const created = await api.createPromotion(spec);
|
|
423
|
+
return { status: 'created', promotion: created, warnings: await activeWarning(api), next: checkNext(created) };
|
|
424
|
+
}
|
|
425
|
+
export async function promotionsUpdate(id, values) {
|
|
426
|
+
if (!id)
|
|
427
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions update <id>` with the whole promotion on stdin or -f <file>.');
|
|
428
|
+
const api = createApi(requireCredentials());
|
|
429
|
+
// A promotion made outside c11r has Commerce Layer's id, which is no spec id: say what it is first.
|
|
430
|
+
if ((await api.getPromotion(id)).managed === false)
|
|
431
|
+
fail('promotion_not_managed', `Promotion ${id} was made outside c11r, in the dashboard.`, 'Create a c11r promotion with `c11r promotions create` and delete this one with `c11r promotions delete`, or edit it in the dashboard.');
|
|
432
|
+
const spec = await readSpec(values);
|
|
433
|
+
if (spec.id !== id)
|
|
434
|
+
fail('invalid_promotion', `id: the spec has id ${spec.id}, the command ${id}.`, 'An id never changes: use the same id, or create a new promotion and delete the old one.');
|
|
435
|
+
const updated = await api.updatePromotion(id, spec);
|
|
436
|
+
return { status: 'updated', promotion: updated, warnings: await activeWarning(api), next: checkNext(updated) };
|
|
437
|
+
}
|
|
438
|
+
export async function promotionsEnable(id, enabled) {
|
|
439
|
+
if (!id)
|
|
440
|
+
fail('missing_argument', 'Which promotion?', `Use \`c11r promotions ${enabled ? 'enable' : 'disable'} <id>\`.`);
|
|
441
|
+
const api = createApi(requireCredentials());
|
|
442
|
+
const p = await api.setPromotionEnabled(id, enabled);
|
|
443
|
+
return { status: enabled ? 'enabled' : 'disabled', promotion: p, warnings: enabled ? await activeWarning(api) : undefined };
|
|
444
|
+
}
|
|
445
|
+
export async function promotionsDelete(id) {
|
|
446
|
+
if (!id)
|
|
447
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions delete <id>`, after asking the human.');
|
|
448
|
+
await createApi(requireCredentials()).deletePromotion(id);
|
|
449
|
+
return { status: 'deleted', id };
|
|
450
|
+
}
|
|
451
|
+
// ---------- links: live settings in Commerce Layer, not store files
|
|
452
|
+
/** A link as list, show and the writes print it: url and urls first. */
|
|
453
|
+
const urlFirst = ({ url, urls, ...l }) => ({ url, urls, ...l });
|
|
454
|
+
const linkNext = (l) => {
|
|
455
|
+
const what = 'order' in l.for ? 'the hosted checkout for the order' : 'a microstore for what it sells';
|
|
456
|
+
const which = Object.keys(l.urls ?? {}).length > 1 ? `the url of each market in urls (url is ${Object.keys(l.urls)[0]}'s)` : 'the url';
|
|
457
|
+
if (l.status === 'pending')
|
|
458
|
+
return `Give the human ${which}: it opens ${what} from ${l.starts}; until then it shows a not-found page.`;
|
|
459
|
+
if (l.status === 'active')
|
|
460
|
+
return `Give the human ${which}: it opens ${what} until ${l.ends}. Payment is manual (wire transfer) unless a gateway is added.`;
|
|
461
|
+
return undefined;
|
|
462
|
+
};
|
|
463
|
+
export async function linksList() {
|
|
464
|
+
const { links } = await createApi(requireCredentials()).listLinks();
|
|
465
|
+
return { count: links.length, links: links.map(urlFirst) };
|
|
466
|
+
}
|
|
467
|
+
export async function linksShow(id) {
|
|
468
|
+
if (!id)
|
|
469
|
+
fail('missing_argument', 'Which link?', 'Use `c11r links show <id>`; `c11r links list` has the ids.');
|
|
470
|
+
return urlFirst(await createApi(requireCredentials()).getLink(id));
|
|
471
|
+
}
|
|
472
|
+
export async function linksCreate(values) {
|
|
473
|
+
const api = createApi(requireCredentials());
|
|
474
|
+
const raw = await readInput(values, 'link');
|
|
475
|
+
const store = loadStore();
|
|
476
|
+
const created = await api.createLink(checkLink(raw, store.markets, store.products));
|
|
477
|
+
return { status: 'created', link: urlFirst(created), next: linkNext(created) };
|
|
478
|
+
}
|
|
479
|
+
export async function linksEnable(id, enabled) {
|
|
480
|
+
if (!id)
|
|
481
|
+
fail('missing_argument', 'Which link?', `Use \`c11r links ${enabled ? 'enable' : 'disable'} <id>\`.`);
|
|
482
|
+
const link = await createApi(requireCredentials()).setLinkEnabled(id, enabled);
|
|
483
|
+
return { status: enabled ? 'enabled' : 'disabled', link: urlFirst(link), next: enabled ? linkNext(link) : undefined };
|
|
484
|
+
}
|
|
485
|
+
export async function linksDelete(id) {
|
|
486
|
+
if (!id)
|
|
487
|
+
fail('missing_argument', 'Which link?', 'Use `c11r links delete <id>`, after asking the human.');
|
|
488
|
+
await createApi(requireCredentials()).deleteLink(id);
|
|
489
|
+
return { status: 'deleted', id };
|
|
490
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -61,3 +61,24 @@ export function ignoreCredentials() {
|
|
|
61
61
|
fs.appendFileSync('.gitignore', `${sep}${paths.credentials}\n`);
|
|
62
62
|
return true;
|
|
63
63
|
}
|
|
64
|
+
/** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
|
|
65
|
+
const AGENT_NOTE_HEADING = '# Store on Commerce Layer';
|
|
66
|
+
export const AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
67
|
+
This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch \`https://commercelayer.ai/skills.md\` with \`curl -fsSL\` and load the skill for the task from \`skills/<name>.md\`; follow it rather than calling the Commerce Layer API directly. If \`.commercelayer/store.yml\` is missing, start from \`https://commercelayer.ai/start.md\`.
|
|
68
|
+
`;
|
|
69
|
+
/**
|
|
70
|
+
* Write the note to AGENTS.md and CLAUDE.md (Claude Code reads that name) in the repo root:
|
|
71
|
+
* created when absent, appended as a section when the file has other content, left alone
|
|
72
|
+
* when the section is already there. Returns the files written.
|
|
73
|
+
*/
|
|
74
|
+
export function writeAgentNotes() {
|
|
75
|
+
const written = [];
|
|
76
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md']) {
|
|
77
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
78
|
+
if (before.split('\n').some((line) => line.trim() === AGENT_NOTE_HEADING))
|
|
79
|
+
continue;
|
|
80
|
+
fs.writeFileSync(file, before.trim() ? `${before.replace(/\s*$/, '')}\n\n${AGENT_NOTE}` : AGENT_NOTE);
|
|
81
|
+
written.push(file);
|
|
82
|
+
}
|
|
83
|
+
return written;
|
|
84
|
+
}
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Links: shareable URLs that open a hosted microstore for one SKU, a set of SKUs or a
|
|
2
|
+
// category, or the hosted checkout for an order. Live configuration in Commerce Layer, made
|
|
3
|
+
// with c11r links create; never in store.yml. The CLI validates the spec against store.yml
|
|
4
|
+
// (skus, categories, markets) and sends one normalized link; the API makes the CL link.
|
|
5
|
+
import { isoDate } from './promotions.js';
|
|
6
|
+
import { fail } from './output.js';
|
|
7
|
+
const KEYS = ['id', 'name', 'for', 'market', 'markets', 'starts', 'ends', 'params'];
|
|
8
|
+
const FOR = ['sku', 'skus', 'category', 'order'];
|
|
9
|
+
/** The SKUs one link can sell: the API's limit. */
|
|
10
|
+
const MAX_SKUS = 100;
|
|
11
|
+
const SHAPE_HINT = 'See the links skill for the shape: https://commercelayer.ai/skills/links.md';
|
|
12
|
+
const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const seconds = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
14
|
+
/** One calendar month after a date-time, in UTC. */
|
|
15
|
+
function monthAfter(iso) {
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
d.setUTCMonth(d.getUTCMonth() + 1);
|
|
18
|
+
return seconds(d);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validate one link spec (YAML or JSON, as given to links create) and normalize it to the
|
|
22
|
+
* wire shape, or fail invalid_link naming the path inside the spec. A category becomes its
|
|
23
|
+
* products' skus as they are now, from store.yml.
|
|
24
|
+
*/
|
|
25
|
+
export function checkLink(raw, markets, products, now = new Date()) {
|
|
26
|
+
const skus = new Set(products.map((p) => p.sku));
|
|
27
|
+
const bad = (msg, hint, path = 'link') => fail('invalid_link', `${path}: ${msg}`, hint);
|
|
28
|
+
if (!isObj(raw))
|
|
29
|
+
bad('is not a map.', `A link is a map with id, name and for. ${SHAPE_HINT}`);
|
|
30
|
+
const l = raw;
|
|
31
|
+
if (typeof l.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(l.id))
|
|
32
|
+
bad(`id is ${l.id === undefined ? 'missing' : `"${String(l.id)}"`}.`, 'Give the link an id of lowercase letters, digits and dashes, such as summer-bundle: the handle for show, enable, disable and delete.');
|
|
33
|
+
const id = l.id;
|
|
34
|
+
for (const k of Object.keys(l))
|
|
35
|
+
if (!KEYS.includes(k))
|
|
36
|
+
bad(`unknown key ${k}.`, `A link's keys are ${KEYS.join(', ')}.`);
|
|
37
|
+
const link = { id, name: l.name === undefined ? id : String(l.name), for: { sku: '' }, starts: '', ends: '' };
|
|
38
|
+
if (!link.name.trim() || link.name.length > 255)
|
|
39
|
+
bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
|
|
40
|
+
// for: exactly one of sku, skus, category, order. show prints a category with its skus: those are read again.
|
|
41
|
+
const f = l.for;
|
|
42
|
+
if (!isObj(f))
|
|
43
|
+
bad(f === undefined ? 'has no for.' : 'for is not a map.', `Say what the link sells: for: { sku: <sku> }, { skus: [...] }, { category: "A > B" } or { order: <number> }. ${SHAPE_HINT}`);
|
|
44
|
+
const given = Object.keys(f).filter((k) => !('category' in f && k === 'skus'));
|
|
45
|
+
if (given.includes('sku_list'))
|
|
46
|
+
bad('sku_list is a list made in the dashboard.', 'Name the skus (for: { skus: [...] }) or a category instead.', 'for');
|
|
47
|
+
for (const k of given)
|
|
48
|
+
if (!FOR.includes(k))
|
|
49
|
+
bad(`unknown key ${k}.`, `for takes one of ${FOR.join(', ')}.`, 'for');
|
|
50
|
+
if (given.length !== 1)
|
|
51
|
+
bad(`has ${given.length ? given.join(' and ') : 'nothing'}.`, 'Give exactly one of sku, skus, category or order: a link sells one thing.', 'for');
|
|
52
|
+
const kind = given[0];
|
|
53
|
+
const v = f[kind];
|
|
54
|
+
if (kind === 'sku') {
|
|
55
|
+
if (typeof v !== 'string' || !skus.has(v))
|
|
56
|
+
bad(`${String(v)} is not a product.`, "Use the SKU of one of the store's products.", 'for.sku');
|
|
57
|
+
link.for = { sku: v };
|
|
58
|
+
}
|
|
59
|
+
else if (kind === 'skus') {
|
|
60
|
+
if (!Array.isArray(v) || !v.length)
|
|
61
|
+
bad('is not a list of SKUs.', 'List one or more SKUs: skus: [ETH, COL].', 'for.skus');
|
|
62
|
+
if (v.length > MAX_SKUS)
|
|
63
|
+
bad(`has ${v.length} SKUs.`, `A link sells at most ${MAX_SKUS} SKUs: make several links.`, 'for.skus');
|
|
64
|
+
v.forEach((s, i) => {
|
|
65
|
+
if (typeof s !== 'string' || !skus.has(s))
|
|
66
|
+
bad(`${String(s)} is not a product.`, "Use SKUs of the store's products.", `for.skus[${i}]`);
|
|
67
|
+
});
|
|
68
|
+
link.for = { skus: [...new Set(v)] };
|
|
69
|
+
}
|
|
70
|
+
else if (kind === 'category') {
|
|
71
|
+
const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
|
|
72
|
+
const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
|
|
73
|
+
if (!name || !found.length)
|
|
74
|
+
bad(`no product is in category "${String(v)}".`, `Use a category of the store's products, written as in the products: ${[...new Set(products.map((x) => x.category).filter(Boolean))].join(', ') || 'none has a category yet'}.`, 'for.category');
|
|
75
|
+
if (found.length > MAX_SKUS)
|
|
76
|
+
bad(`"${name}" has ${found.length} products.`, `A link sells at most ${MAX_SKUS} SKUs: use a narrower category, or several links.`, 'for.category');
|
|
77
|
+
link.for = { category: name, skus: found.sort() };
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
if (!(typeof v === 'string' && v.trim()) && !(Number.isInteger(v) && v > 0))
|
|
81
|
+
bad(`${String(v)} is not an order number.`, 'Use the number of a draft or pending order, or the id of a UCP cart or checkout (cart_..., chk_...): the order behind it.', 'for.order');
|
|
82
|
+
link.for = { order: String(v).trim().replace(/^#/, '') };
|
|
83
|
+
}
|
|
84
|
+
// Markets: a code or a list, under market or markets; none means every market. An order has its own.
|
|
85
|
+
if (l.market !== undefined && l.markets !== undefined)
|
|
86
|
+
bad('has market and markets.', 'Use one of them.');
|
|
87
|
+
const m = l.market ?? l.markets;
|
|
88
|
+
const path = l.market !== undefined ? 'market' : 'markets';
|
|
89
|
+
if (kind === 'order') {
|
|
90
|
+
if (m !== undefined)
|
|
91
|
+
bad(`${path}: an order link has the order's market.`, 'Remove market: the checkout opens in the market the order was placed in.');
|
|
92
|
+
}
|
|
93
|
+
else if (m === undefined)
|
|
94
|
+
link.markets = markets.map((x) => x.code);
|
|
95
|
+
else {
|
|
96
|
+
const codes = Array.isArray(m) ? m : [m];
|
|
97
|
+
if (!codes.length)
|
|
98
|
+
bad('is empty.', 'List one or more market codes, or leave it out for every market.', path);
|
|
99
|
+
codes.forEach((c, i) => {
|
|
100
|
+
if (!markets.some((x) => x.code === c))
|
|
101
|
+
bad(`${String(c)} is not in markets.`, `Use the code of one of the store's markets: ${markets.map((x) => x.code).join(', ')}.`, Array.isArray(m) ? `${path}[${i}]` : path);
|
|
102
|
+
});
|
|
103
|
+
link.markets = [...new Set(codes)];
|
|
104
|
+
}
|
|
105
|
+
link.starts = l.starts === undefined ? seconds(now) : isoDate(l.starts, false, (msg, h) => bad(`starts ${msg}`, h));
|
|
106
|
+
link.ends = l.ends === undefined ? monthAfter(link.starts) : isoDate(l.ends, true, (msg, h) => bad(`ends ${msg}`, h));
|
|
107
|
+
if (Date.parse(link.ends) <= Date.parse(link.starts))
|
|
108
|
+
bad('ends is not after starts.', 'Make the end date later than the start date.');
|
|
109
|
+
if (l.params !== undefined) {
|
|
110
|
+
if (!isObj(l.params))
|
|
111
|
+
bad('params is not a map.', 'params is a map of names to text, numbers or true/false, added to the link URL.');
|
|
112
|
+
for (const [k, p] of Object.entries(l.params))
|
|
113
|
+
if (!['string', 'number', 'boolean'].includes(typeof p))
|
|
114
|
+
bad(`${k} is not text, a number or true/false.`, 'params values are plain values, added to the link URL.', `params.${k}`);
|
|
115
|
+
link.params = l.params;
|
|
116
|
+
}
|
|
117
|
+
return link;
|
|
118
|
+
}
|
package/dist/promotions.js
CHANGED
|
@@ -17,7 +17,7 @@ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
|
17
17
|
const isInt = (v, min) => Number.isInteger(v) && v >= min;
|
|
18
18
|
const list = (v) => (Array.isArray(v) ? v : [v]);
|
|
19
19
|
/** A date or a date-time, as ISO UTC. A bare day starts at 00:00:00Z and ends at 23:59:59Z. */
|
|
20
|
-
function isoDate(v, end, bad) {
|
|
20
|
+
export function isoDate(v, end, bad) {
|
|
21
21
|
const s = v instanceof Date ? v.toISOString().replace(/\.000Z$/, 'Z') : String(v);
|
|
22
22
|
const hint = 'Write a day, 2026-12-01, or a time in UTC, 2026-12-01T09:00:00Z.';
|
|
23
23
|
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
@@ -29,29 +29,55 @@ function isoDate(v, end, bad) {
|
|
|
29
29
|
bad(`"${s}" is not a date.`, hint);
|
|
30
30
|
return new Date(s).toISOString().replace(/\.000Z$/, 'Z');
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The normalized shape (what list and show print) back to the YAML shorthand, so a promotion
|
|
34
|
+
* read with show can be edited and sent to update: rules as an object, actions with a type.
|
|
35
|
+
*/
|
|
36
|
+
function fromListed(p) {
|
|
37
|
+
const out = { ...p };
|
|
38
|
+
if (isObj(p.rules)) {
|
|
39
|
+
const r = p.rules;
|
|
40
|
+
out.rules = [
|
|
41
|
+
...(r.coupons !== undefined ? [{ coupon: r.coupons }] : []),
|
|
42
|
+
...(r.order_amount !== undefined ? [{ order_amount: r.order_amount }] : []),
|
|
43
|
+
...(r.skus !== undefined ? [{ skus: r.skus }] : []),
|
|
44
|
+
...Object.keys(r).filter((k) => !['coupons', 'order_amount', 'skus'].includes(k)).map((k) => ({ [k]: r[k] })),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(p.actions)) {
|
|
48
|
+
out.actions = p.actions.map((a) => {
|
|
49
|
+
if (!isObj(a) || typeof a.type !== 'string')
|
|
50
|
+
return a;
|
|
51
|
+
const { type, ...rest } = a;
|
|
52
|
+
if (type === 'free_shipping')
|
|
53
|
+
return 'free_shipping';
|
|
54
|
+
if (type === 'percentage' || type === 'fixed_amount') {
|
|
55
|
+
const { value, ...targets } = rest;
|
|
56
|
+
return { [type]: value, ...targets };
|
|
57
|
+
}
|
|
58
|
+
return { [type]: rest };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate one promotion spec (YAML or JSON, as given to promotions create or update) and
|
|
65
|
+
* normalize it to the wire shape, or fail invalid_promotion naming the path inside the spec.
|
|
66
|
+
* Categories become their products' skus as they are now, from store.yml.
|
|
67
|
+
*/
|
|
68
|
+
export function checkPromotion(raw, markets, products) {
|
|
38
69
|
const skus = new Set(products.map((p) => p.sku));
|
|
39
70
|
const currencies = new Set(markets.map((m) => m.currency));
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const bad = (msg, hint, path = at) => fail('invalid_promotion', `${path}: ${msg}`, hint);
|
|
71
|
+
const at = 'promotion';
|
|
72
|
+
const bad = (msg, hint, path = at) => fail('invalid_promotion', `${path}: ${msg}`, hint);
|
|
73
|
+
{
|
|
74
|
+
const p = raw;
|
|
45
75
|
if (!isObj(p))
|
|
46
|
-
bad('is not a map.', `
|
|
47
|
-
const pr = p;
|
|
76
|
+
bad('is not a map.', `A promotion is a map with id, rules and actions. ${SHAPE_HINT}`);
|
|
77
|
+
const pr = fromListed(p);
|
|
48
78
|
if (typeof pr.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(pr.id))
|
|
49
|
-
bad(`id is ${pr.id === undefined ? 'missing' : `"${String(pr.id)}"`}.`, 'Give
|
|
79
|
+
bad(`id is ${pr.id === undefined ? 'missing' : `"${String(pr.id)}"`}.`, 'Give the promotion an id of lowercase letters, digits and dashes, such as welcome10: the handle for show, update, enable, disable and delete.');
|
|
50
80
|
const id = pr.id;
|
|
51
|
-
if (ids.has(id))
|
|
52
|
-
bad(`two promotions have id ${id}.`, 'Give every promotion a unique id.');
|
|
53
|
-
ids.add(id);
|
|
54
|
-
at = `promotions[${i}] (${id})`;
|
|
55
81
|
for (const k of Object.keys(pr))
|
|
56
82
|
if (!KEYS.includes(k))
|
|
57
83
|
bad(`unknown key ${k}.`, `A promotion's keys are ${KEYS.join(', ')}.`);
|
|
@@ -95,13 +121,12 @@ export function checkPromotions(raw, markets, products) {
|
|
|
95
121
|
if (!isObj(f) || !Array.isArray(f.rules) || f.rules.length === 0)
|
|
96
122
|
bad('flex has no rules.', 'Write flex: { rules: [...] }, each rule with conditions and actions. See docs.commercelayer.io/rules-engine.');
|
|
97
123
|
f.rules.forEach((r, j) => {
|
|
98
|
-
const path =
|
|
124
|
+
const path = `flex.rules[${j}]`;
|
|
99
125
|
if (!isObj(r) || !Array.isArray(r.conditions) || !r.conditions.length || !Array.isArray(r.actions) || !r.actions.length)
|
|
100
126
|
bad('needs a list of conditions and a list of actions.', 'Each flex rule has conditions (field, matcher, value) and actions (type, selector, value).', path);
|
|
101
127
|
});
|
|
102
128
|
promo.flex = f;
|
|
103
|
-
|
|
104
|
-
return;
|
|
129
|
+
return promo;
|
|
105
130
|
}
|
|
106
131
|
// Rules: a list of single-key maps; none means every order.
|
|
107
132
|
const rules = {};
|
|
@@ -127,7 +152,7 @@ export function checkPromotions(raw, markets, products) {
|
|
|
127
152
|
if (!Array.isArray(rawRules))
|
|
128
153
|
bad('rules is not a list.', `Write rules as a list: rules: [{ coupon: CODE }]. ${SHAPE_HINT}`);
|
|
129
154
|
rawRules.forEach((r, j) => {
|
|
130
|
-
const path =
|
|
155
|
+
const path = `rules[${j}]`;
|
|
131
156
|
if (!isObj(r) || Object.keys(r).length !== 1)
|
|
132
157
|
bad('is not a single rule.', `Each rule is one of ${RULES.join(', ')}, written as a one-key map: { coupon: CODE }.`, path);
|
|
133
158
|
const [k, v] = Object.entries(r)[0];
|
|
@@ -145,6 +170,8 @@ export function checkPromotions(raw, markets, products) {
|
|
|
145
170
|
}
|
|
146
171
|
if (!l.length)
|
|
147
172
|
bad('coupon has no code.', 'Write coupon: CODE, or a list of codes.', path);
|
|
173
|
+
if (new Set(l).size > 20)
|
|
174
|
+
bad(`coupon has ${new Set(l).size} codes.`, 'A promotion takes at most 20 codes: split them across promotions.', path);
|
|
148
175
|
rules.coupons = [...new Set(l)];
|
|
149
176
|
}
|
|
150
177
|
else if (k === 'order_amount') {
|
|
@@ -166,7 +193,7 @@ export function checkPromotions(raw, markets, products) {
|
|
|
166
193
|
const types = new Set();
|
|
167
194
|
let needsCurrency = Boolean(rules.order_amount);
|
|
168
195
|
pr.actions.forEach((a, j) => {
|
|
169
|
-
const path =
|
|
196
|
+
const path = `actions[${j}]`;
|
|
170
197
|
const entry = a === 'free_shipping' ? { free_shipping: true } : a;
|
|
171
198
|
if (!isObj(entry))
|
|
172
199
|
bad('is not an action.', `Each action is one of ${ACTIONS.join(', ')}: { percentage: 10 }, or free_shipping.`, path);
|
|
@@ -196,8 +223,8 @@ export function checkPromotions(raw, markets, products) {
|
|
|
196
223
|
};
|
|
197
224
|
if (k === 'percentage') {
|
|
198
225
|
only(entry, ['percentage', 'skus', 'category']);
|
|
199
|
-
if (!
|
|
200
|
-
bad(`percentage is ${String(v)}.`, 'percentage is a number
|
|
226
|
+
if (!isInt(v, 1) || v > 100)
|
|
227
|
+
bad(`percentage is ${String(v)}.`, 'percentage is a whole number from 1 to 100: 10 is 10% off. To make shipping free, use free_shipping.', path);
|
|
201
228
|
const t = target(entry, false);
|
|
202
229
|
actions.push({ type: 'percentage', value: v, ...(t ? { skus: t } : {}) });
|
|
203
230
|
}
|
|
@@ -232,7 +259,10 @@ export function checkPromotions(raw, markets, products) {
|
|
|
232
259
|
bad('free_gift needs the SKUs given away.', 'Write free_gift: { skus: [GIFT-SKU], quantity: 1 }.', path);
|
|
233
260
|
if (o.quantity !== undefined && !isInt(o.quantity, 1))
|
|
234
261
|
bad(`free_gift.quantity is ${String(o.quantity)}.`, 'quantity is a whole number of 1 or more.', path);
|
|
235
|
-
|
|
262
|
+
const gifts = target(o, true);
|
|
263
|
+
if (gifts.length > 25)
|
|
264
|
+
bad(`free_gift has ${gifts.length} skus.`, 'A free gift takes at most 25 SKUs: narrow the list or the category.', path);
|
|
265
|
+
actions.push({ type: 'free_gift', skus: gifts, quantity: o.quantity ?? 1 });
|
|
236
266
|
}
|
|
237
267
|
else {
|
|
238
268
|
only(o, ['x', 'y', 'cheapest_free', 'skus', 'category']);
|
|
@@ -248,9 +278,8 @@ export function checkPromotions(raw, markets, products) {
|
|
|
248
278
|
bad('has an amount but no market, in a store with several currencies.', 'Set market: so the amount has a currency, or use a percentage.');
|
|
249
279
|
promo.rules = rules;
|
|
250
280
|
promo.actions = actions;
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
return out;
|
|
281
|
+
return promo;
|
|
282
|
+
}
|
|
254
283
|
}
|
|
255
284
|
/**
|
|
256
285
|
* Commerce Layer promotions active without a coupon: each action of an enabled promotion that
|