@commercelayer/agent-cli 0.2.3 → 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 +16 -2
- package/dist/api/client.js +20 -13
- package/dist/api/stub/management.js +88 -0
- 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 +12 -0
- package/dist/commands.js +65 -10
- package/dist/links.js +118 -0
- package/dist/promotions.js +1 -1
- package/dist/storefront.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,12 +18,15 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
18
18
|
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
|
|
19
19
|
| `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused. While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
|
|
20
20
|
| `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
|
|
21
|
-
| `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image
|
|
21
|
+
| `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image show the tile the store host draws for them, at `/tiles/<sku>.svg`. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
|
|
22
22
|
| `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
|
|
23
23
|
| `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
|
|
24
24
|
| `promotions list` · `show <id>` | The store's promotions, live in Commerce Layer: id, name, status, rules, actions, uses, dates; `managed: false` for ones made in the dashboard. |
|
|
25
25
|
| `promotions create` · `update <id>` | Create a promotion, or replace one, from a spec in YAML or JSON on stdin or `-f <file>` (see [Promotions](#promotions)). Prints the promotion and the `orders test` call that checks it. |
|
|
26
26
|
| `promotions enable <id>` · `disable <id>` · `delete <id>` | Turn a promotion on or off, or delete it (ask the human first). |
|
|
27
|
+
| `links list` · `show <id>` | The store's links, live in Commerce Layer: url, id, name, status, what it sells, markets, dates; `managed: false` for ones made in the dashboard. |
|
|
28
|
+
| `links create` | Create a shareable link to a microstore (sku, skus or category) or an order's checkout, from a spec in YAML or JSON on stdin or `-f <file>` (see [Links](#links)). Prints the link, `url` first. |
|
|
29
|
+
| `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
|
|
27
30
|
| `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order, the `discounts` applied (promotions and codes, in minor units like the totals) and every step. |
|
|
28
31
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
29
32
|
| `orders show <id or number>` | One order. |
|
|
@@ -86,6 +89,17 @@ Optional: `market` (a market code; without it, every market), `starts` and `ends
|
|
|
86
89
|
|
|
87
90
|
`list` and `show` print each promotion in the normalized form plus `status` (`active`, `scheduled`, `expired`, `disabled`, `used_up`) and `uses`; that output can be edited and sent back to `update`. Promotions made outside c11r (the dashboard, the sandbox's `SANDBOX10`) are listed with `managed: false` and Commerce Layer's id: `enable`, `disable` and `delete` work on them, `update` answers `promotion_not_managed`. `percentage` is a whole number from 1 to 100; a promotion takes at most 20 codes, a free gift at most 25 SKUs. More than 10 active promotion actions without a coupon gives a `warnings` entry (Commerce Layer applies 10). `orders test --code <CODE>` proves one: its `discounts` lists what was taken off, automatic promotions with `automatic: true`, and a code that applied nothing is a `discount_code_invalid` warning in `messages`.
|
|
88
91
|
|
|
92
|
+
### Links
|
|
93
|
+
Links are live settings in Commerce Layer too, never 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. A spec, in YAML or JSON on stdin or with `-f <file>`:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
id: single-origins # the handle: lowercase letters, digits, dashes; unique
|
|
97
|
+
name: Single origins
|
|
98
|
+
for: { category: Coffee > Single origin } # or { sku: ETH }, { skus: [ETH, COL] }, { order: 1001 | cart_... }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Optional: `market` (a code or a list; without it, every market; an order link takes the order's market and none), `starts` (default now) and `ends` (default a month after `starts`; a day, or a UTC time; a bare `ends` day runs to 23:59:59Z), `params` (names to plain values, added to the URL). A category becomes the skus of its products in store.yml when the command runs; a link sells 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 (`cart_...`, `chk_...`), read back as the order's number. A placed order opens its thank-you page, so `create` refuses it. Commerce Layer opens a link in one market, so a link has a URL per market: `urls`, by market code, and `url`, the first market's. `create`, `list` and `show` print `url` and `urls` first, then the spec, `status` (`pending`, `active`, `expired`, `disabled`: the first market's; enable and disable act on every market) and `created_at`. A disabled or pending link opens Commerce Layer's not-found page; checkout takes the sandbox's manual payment (wire transfer) unless a gateway is added. A link cannot change: delete it and create it again (what `show` prints can be sent back to `create`). Links made outside c11r are listed with `managed: false` and Commerce Layer's id, and may sell `for: { sku_list: <name> }`; `enable`, `disable` and `delete` work on them.
|
|
102
|
+
|
|
89
103
|
### The products folder
|
|
90
104
|
Beyond a handful of products, put the catalog in `.commercelayer/products/` and leave `products` out of `store.yml`. Every file in the folder is merged into the catalog by sku, in file-name order:
|
|
91
105
|
- `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
|
|
@@ -117,7 +131,7 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
117
131
|
|
|
118
132
|
A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
|
|
119
133
|
|
|
120
|
-
Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
134
|
+
Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
121
135
|
## The store speaks UCP
|
|
122
136
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
123
137
|
| What | Where |
|
package/dist/api/client.js
CHANGED
|
@@ -27,37 +27,44 @@ export function createApi(auth) {
|
|
|
27
27
|
fail(err.code, err.message, err.hint);
|
|
28
28
|
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
29
29
|
};
|
|
30
|
-
// Promotions: on an API older than them, the collection route is an unknown route (404 not_found).
|
|
31
|
-
const
|
|
32
|
-
const one = (id) => `${
|
|
33
|
-
const unsupported = async (call) => {
|
|
30
|
+
// Promotions and links: on an API older than them, the collection route is an unknown route (404 not_found).
|
|
31
|
+
const collection = (kind) => `${org()}/${kind}`;
|
|
32
|
+
const one = (kind, id) => `${collection(kind)}/${encodeURIComponent(id)}`;
|
|
33
|
+
const unsupported = async (kind, call) => {
|
|
34
34
|
try {
|
|
35
35
|
return await call;
|
|
36
36
|
}
|
|
37
37
|
catch (e) {
|
|
38
38
|
if (e instanceof CliError && (e.code === 'not_found' || (e.code === 'api_error' && /\b404\b/.test(e.message))))
|
|
39
|
-
fail(
|
|
39
|
+
fail(`${kind}_unsupported`, `This API does not manage ${kind} yet.`, 'Try again later; tell the human if it persists.');
|
|
40
40
|
throw e;
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
|
-
const found = async (id, call) => {
|
|
43
|
+
const found = async (kind, id, call) => {
|
|
44
44
|
try {
|
|
45
45
|
return await call;
|
|
46
46
|
}
|
|
47
47
|
catch (e) {
|
|
48
48
|
if (e instanceof CliError && e.code === 'not_found')
|
|
49
|
-
fail('not_found', `There is no
|
|
49
|
+
fail('not_found', `There is no ${kind.slice(0, -1)} ${id}.`, `Run \`c11r ${kind} list\` for the ids, or \`c11r ${kind} create\` to make it.`);
|
|
50
50
|
throw e;
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
return {
|
|
54
|
-
listPromotions: () => unsupported(request('GET', promotions
|
|
55
|
-
createPromotion: (spec) => unsupported(request('POST', promotions
|
|
56
|
-
getPromotion: (id) => found(id, request('GET', one(id))),
|
|
57
|
-
updatePromotion: (id, spec) => found(id, request('PUT', one(id), spec)),
|
|
58
|
-
setPromotionEnabled: (id, enabled) => found(id, request('POST', `${one(id)}/${enabled ? 'enable' : 'disable'}`)),
|
|
54
|
+
listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
|
|
55
|
+
createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
|
|
56
|
+
getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
|
|
57
|
+
updatePromotion: (id, spec) => found('promotions', id, request('PUT', one('promotions', id), spec)),
|
|
58
|
+
setPromotionEnabled: (id, enabled) => found('promotions', id, request('POST', `${one('promotions', id)}/${enabled ? 'enable' : 'disable'}`)),
|
|
59
59
|
deletePromotion: async (id) => {
|
|
60
|
-
await found(id, request('DELETE', one(id)));
|
|
60
|
+
await found('promotions', id, request('DELETE', one('promotions', id)));
|
|
61
|
+
},
|
|
62
|
+
listLinks: () => unsupported('links', request('GET', collection('links'))),
|
|
63
|
+
createLink: (spec) => unsupported('links', request('POST', collection('links'), spec)),
|
|
64
|
+
getLink: (id) => found('links', id, request('GET', one('links', id))),
|
|
65
|
+
setLinkEnabled: (id, enabled) => found('links', id, request('POST', `${one('links', id)}/${enabled ? 'enable' : 'disable'}`)),
|
|
66
|
+
deleteLink: async (id) => {
|
|
67
|
+
await found('links', id, request('DELETE', one('links', id)));
|
|
61
68
|
},
|
|
62
69
|
createSandbox: (name) => request('POST', '/sandboxes', { name }),
|
|
63
70
|
getStore: () => request('GET', org()),
|
|
@@ -59,6 +59,52 @@ function unknownSkus(r, spec) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
/** As the API lists it: the spec plus the first market's url, every url, and status. */
|
|
63
|
+
function listedLink(l) {
|
|
64
|
+
const { enabled, urls, ...link } = l;
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const status = !enabled ? 'disabled' : Date.parse(l.ends) <= now ? 'expired' : Date.parse(l.starts) > now ? 'pending' : 'active';
|
|
67
|
+
return { ...link, url: Object.values(urls)[0], urls, status, managed: true };
|
|
68
|
+
}
|
|
69
|
+
/** The API checks a link against the store: applied skus and markets, draft orders. Answers the markets it gets a link in. */
|
|
70
|
+
function linkMarkets(r, spec) {
|
|
71
|
+
const known = r.applied?.products ?? {};
|
|
72
|
+
const f = spec.for;
|
|
73
|
+
const list = 'sku' in f ? [['for.sku', f.sku]] : 'skus' in f ? f.skus.map((s, i) => [`for.skus[${i}]`, s]) : [];
|
|
74
|
+
for (const [path, sku] of list)
|
|
75
|
+
if (!(sku in known))
|
|
76
|
+
return error(422, 'invalid_link', `${path}: unknown sku ${sku}.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
|
|
77
|
+
if ('order' in f) {
|
|
78
|
+
if (spec.markets)
|
|
79
|
+
return error(422, 'invalid_link', "markets: an order link uses the order's market.", 'Leave markets out.');
|
|
80
|
+
// A UCP cart or checkout id names the draft order behind it, read back as its number (the stub's: the id's suffix).
|
|
81
|
+
const cart = /^cart_/.test(f.order) ? r.ucp.carts[f.order] : undefined;
|
|
82
|
+
// A cart is the same order as the checkout made from it.
|
|
83
|
+
const ucp = cart ? (Object.values(r.ucp.checkouts).find((c) => c._cart_id === f.order && c.status === 'completed') ?? cart) : /^chk_/.test(f.order) ? r.ucp.checkouts[f.order] : undefined;
|
|
84
|
+
if (ucp) {
|
|
85
|
+
const placed = ucp.order?.label;
|
|
86
|
+
if (ucp.status === 'completed' && placed)
|
|
87
|
+
return error(422, 'invalid_link', `for.order: order ${placed.replace(/^#/, '')} is placed: a link opens the checkout of a draft or pending order.`, 'Link a cart or checkout that is not completed.');
|
|
88
|
+
if (ucp.status === 'canceled')
|
|
89
|
+
return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'The checkout is canceled: link another cart or checkout.');
|
|
90
|
+
const market = Object.values(r.applied?.markets ?? {}).find((m) => m.currency === ucp.currency)?.code ?? Object.keys(r.applied?.markets ?? {})[0];
|
|
91
|
+
spec.for = { order: f.order.replace(/^(cart|chk)_/, '') };
|
|
92
|
+
return [market];
|
|
93
|
+
}
|
|
94
|
+
const draft = r.draft_orders?.[f.order];
|
|
95
|
+
if (draft)
|
|
96
|
+
return [draft.market];
|
|
97
|
+
if (publicOrders(r).some((o) => o.label.replace(/^#/, '') === f.order))
|
|
98
|
+
return error(422, 'invalid_link', `for.order: order ${f.order} is placed: a link opens the checkout of a draft or pending order.`, 'A placed order opens its thank-you page, not a checkout: link a draft or pending order.');
|
|
99
|
+
return error(422, 'invalid_link', `for.order: no order ${f.order}.`, 'Use the number of a draft or pending order in the store.');
|
|
100
|
+
}
|
|
101
|
+
const applied = Object.keys(r.applied?.markets ?? {});
|
|
102
|
+
const markets = spec.markets ?? applied;
|
|
103
|
+
const i = markets.findIndex((m) => !applied.includes(m));
|
|
104
|
+
if (i >= 0)
|
|
105
|
+
return error(422, 'invalid_link', `markets[${i}]: unknown market ${markets[i]}.`, 'Use markets that are applied: run `c11r apply` first if you just added it.');
|
|
106
|
+
return markets;
|
|
107
|
+
}
|
|
62
108
|
export function handleManagement(req) {
|
|
63
109
|
const db = readDb();
|
|
64
110
|
const path = req.url.pathname;
|
|
@@ -211,6 +257,48 @@ export function handleManagement(req) {
|
|
|
211
257
|
}
|
|
212
258
|
}
|
|
213
259
|
}
|
|
260
|
+
// Links: live, synchronous, keyed by id; one Commerce Layer link per market.
|
|
261
|
+
if (rest === '/links' || rest.startsWith('/links/')) {
|
|
262
|
+
const store = (record.links ??= {});
|
|
263
|
+
const [, , rawId, verb] = rest.split('/');
|
|
264
|
+
const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
|
|
265
|
+
const save = () => writeDb(db);
|
|
266
|
+
if (id === undefined) {
|
|
267
|
+
if (req.method === 'GET')
|
|
268
|
+
return json(200, { links: Object.keys(store).sort().map((k) => listedLink(store[k])) });
|
|
269
|
+
if (req.method === 'POST') {
|
|
270
|
+
const spec = readJson(req);
|
|
271
|
+
if (store[spec.id])
|
|
272
|
+
return error(409, 'link_exists', `There is already a link ${spec.id}.`, 'Pick another id, or delete the link and create it again.');
|
|
273
|
+
const markets = linkMarkets(record, spec);
|
|
274
|
+
if (!Array.isArray(markets))
|
|
275
|
+
return markets;
|
|
276
|
+
// One Commerce Layer link per market: a scope with several markets does not open.
|
|
277
|
+
const urls = Object.fromEntries(markets.map((m) => [m, `https://c11.page/${crypto.randomBytes(6).toString('base64url')}`]));
|
|
278
|
+
// An order link reads back with the order's market.
|
|
279
|
+
store[spec.id] = { ...spec, markets, urls, enabled: true, created_at: new Date().toISOString() };
|
|
280
|
+
save();
|
|
281
|
+
return json(201, listedLink(store[spec.id]));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
const l = store[id];
|
|
286
|
+
if (!l)
|
|
287
|
+
return error(404, 'not_found', `There is no link ${id}.`, 'Run `c11r links list`.');
|
|
288
|
+
if (verb === undefined && req.method === 'GET')
|
|
289
|
+
return json(200, listedLink(l));
|
|
290
|
+
if (verb === undefined && req.method === 'DELETE') {
|
|
291
|
+
delete store[id];
|
|
292
|
+
save();
|
|
293
|
+
return { status: 204, headers: {}, body: '' };
|
|
294
|
+
}
|
|
295
|
+
if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
|
|
296
|
+
l.enabled = verb === 'enable';
|
|
297
|
+
save();
|
|
298
|
+
return json(200, listedLink(l));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
214
302
|
if (req.method === 'PUT' && rest === '/assets') {
|
|
215
303
|
const { files } = readJson(req);
|
|
216
304
|
let total = 0;
|
|
@@ -86,8 +86,9 @@ function returnPolicy(ctx) {
|
|
|
86
86
|
];
|
|
87
87
|
}
|
|
88
88
|
// ---------- catalog
|
|
89
|
-
/** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL
|
|
90
|
-
|
|
89
|
+
/** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL,
|
|
90
|
+
* and for a product without an image, its tile. */
|
|
91
|
+
const imageUrl = (ctx, p) => new URL(p.image_url ?? `tiles/${encodeURIComponent(p.sku)}.svg`, `${ctx.origin}/`).href;
|
|
91
92
|
function variant(ctx, p, market) {
|
|
92
93
|
const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
|
|
93
94
|
const v = {
|
|
@@ -100,8 +101,7 @@ function variant(ctx, p, market) {
|
|
|
100
101
|
// Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
|
|
101
102
|
availability: { available: true, status: 'in_stock' },
|
|
102
103
|
};
|
|
103
|
-
|
|
104
|
-
v.media = [{ type: 'image', url: imageUrl(ctx, p.image_url) }];
|
|
104
|
+
v.media = [{ type: 'image', url: imageUrl(ctx, p) }];
|
|
105
105
|
if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
|
|
106
106
|
const o = shippingOption(market);
|
|
107
107
|
v.fulfillment = { methods: [{ type: 'shipping', description: { plain: `Ships to ${market.countries.join(', ')}` }, availability: v.availability, options: [{ id: o.id, title: o.title, description: o.description }] }] };
|
|
@@ -202,8 +202,7 @@ function buildLines(ctx, market, requested, existing = []) {
|
|
|
202
202
|
const qty = quantity;
|
|
203
203
|
const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
|
|
204
204
|
const item = { id: p.sku, title: p.name, price };
|
|
205
|
-
|
|
206
|
-
item.image_url = imageUrl(ctx, p.image_url);
|
|
205
|
+
item.image_url = imageUrl(ctx, p);
|
|
207
206
|
lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
|
|
208
207
|
});
|
|
209
208
|
return { lines, messages };
|
package/dist/api/stub/theme.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// custom storefront: the same settings in the same places (accent, logo or wordmark,
|
|
3
3
|
// hero image or band, tagline), the products by top-level category, a page per product
|
|
4
4
|
// at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
|
|
5
|
+
// A product without an image shows its tile, and an unset hero the generated one, both
|
|
6
|
+
// drawn at /tiles/ (renderTile) as the store host does.
|
|
5
7
|
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
6
8
|
/** Black or white, whichever reads better on the accent. */
|
|
7
9
|
function onAccent(hex) {
|
|
@@ -21,7 +23,8 @@ export function renderTheme(r, path, search) {
|
|
|
21
23
|
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
22
24
|
const logo = theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`;
|
|
23
25
|
const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
|
|
24
|
-
const
|
|
26
|
+
const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg" alt="${esc(p.name)}" loading="lazy"></span>`;
|
|
27
|
+
const card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(p.name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
|
|
25
28
|
let title = name;
|
|
26
29
|
let body;
|
|
27
30
|
const m = path.match(/^\/products\/([^/]+)$/);
|
|
@@ -31,7 +34,7 @@ export function renderTheme(r, path, search) {
|
|
|
31
34
|
const top = p.category?.split(' > ')[0] ?? 'Other';
|
|
32
35
|
groups.set(top, [...(groups.get(top) ?? []), p]);
|
|
33
36
|
}
|
|
34
|
-
const hero =
|
|
37
|
+
const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
|
|
35
38
|
body = `${hero}<h1>${esc(name)}</h1><p class="tagline">${esc(tagline)}</p></section><main>${[...groups].map(([k, ps]) => `<section><h2>${esc(k)}</h2><div class="grid">${ps.map(card).join('')}</div></section>`).join('')}</main>`;
|
|
36
39
|
}
|
|
37
40
|
else if (m) {
|
|
@@ -39,7 +42,7 @@ export function renderTheme(r, path, search) {
|
|
|
39
42
|
if (!p)
|
|
40
43
|
return null;
|
|
41
44
|
title = `${p.name} · ${name}`;
|
|
42
|
-
body = `<main class="product-page">${
|
|
45
|
+
body = `<main class="product-page">${image(p)}<div>${p.category ? `<p class="category">${esc(p.category)}</p>` : ''}<h1>${esc(p.name)}</h1>${p.description ? `<p>${esc(p.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
|
|
43
46
|
}
|
|
44
47
|
else
|
|
45
48
|
return null;
|
|
@@ -61,3 +64,29 @@ ${body}
|
|
|
61
64
|
`;
|
|
62
65
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
63
66
|
}
|
|
67
|
+
const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
68
|
+
/** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
|
|
69
|
+
export function renderTile(r, path) {
|
|
70
|
+
const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
|
|
71
|
+
if (!m)
|
|
72
|
+
return null;
|
|
73
|
+
const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(r.theme?.color ?? '') ? r.theme.color : '#2f5d50';
|
|
74
|
+
const headers = { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' };
|
|
75
|
+
let svg;
|
|
76
|
+
if (m[1] === 'hero')
|
|
77
|
+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="900" viewBox="0 0 2400 900"><rect width="2400" height="900" fill="${color}"/><circle cx="1900" cy="450" r="360" fill="#ffffff" fill-opacity="0.12"/></svg>`;
|
|
78
|
+
else {
|
|
79
|
+
let sku;
|
|
80
|
+
try {
|
|
81
|
+
sku = decodeURIComponent(m[1]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const p = r.applied?.products[sku];
|
|
87
|
+
if (!p)
|
|
88
|
+
return null;
|
|
89
|
+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200"><rect width="1200" height="1200" fill="${color}" fill-opacity="0.08"/><text x="600" y="600" text-anchor="middle" font-family="system-ui, sans-serif" font-size="72" fill="${color}">${xml(p.name.slice(0, 60))}</text></svg>`;
|
|
90
|
+
}
|
|
91
|
+
return { status: 200, headers, body: svg };
|
|
92
|
+
}
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { contentDigest, signHeaders } from '../../ucp/signing.js';
|
|
|
8
8
|
import { strip } from './db.js';
|
|
9
9
|
import { json } from './http.js';
|
|
10
10
|
import * as shop from './shopping.js';
|
|
11
|
-
import { renderTheme } from './theme.js';
|
|
11
|
+
import { renderTheme, renderTile } from './theme.js';
|
|
12
12
|
const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
|
|
13
13
|
const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
|
|
14
14
|
// ---------- profile
|
|
@@ -305,6 +305,9 @@ function handlePermalink(req, r, origin, path) {
|
|
|
305
305
|
const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif', ico: 'image/x-icon', txt: 'text/plain' };
|
|
306
306
|
function serveStorefront(r, path, search) {
|
|
307
307
|
const files = r.ucp.files;
|
|
308
|
+
// /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
|
|
309
|
+
if (path.startsWith('/tiles/'))
|
|
310
|
+
return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
308
311
|
// A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
|
|
309
312
|
if (files['index.html'] === undefined) {
|
|
310
313
|
const page = renderTheme(r, path, search);
|
package/dist/bin.js
CHANGED
|
@@ -71,6 +71,18 @@ async function run() {
|
|
|
71
71
|
if (sub === 'delete')
|
|
72
72
|
return cmd.promotionsDelete(args[2]);
|
|
73
73
|
throw new CliError('unknown_command', `There is no command "promotions ${sub ?? ''}".`, 'Use `c11r promotions list`, `show <id>`, `create`, `update <id>`, `enable <id>`, `disable <id>` or `delete <id>`.');
|
|
74
|
+
case 'links':
|
|
75
|
+
if (sub === 'list')
|
|
76
|
+
return cmd.linksList();
|
|
77
|
+
if (sub === 'show')
|
|
78
|
+
return cmd.linksShow(args[2]);
|
|
79
|
+
if (sub === 'create')
|
|
80
|
+
return cmd.linksCreate(values);
|
|
81
|
+
if (sub === 'enable' || sub === 'disable')
|
|
82
|
+
return cmd.linksEnable(args[2], sub === 'enable');
|
|
83
|
+
if (sub === 'delete')
|
|
84
|
+
return cmd.linksDelete(args[2]);
|
|
85
|
+
throw new CliError('unknown_command', `There is no command "links ${sub ?? ''}".`, 'Use `c11r links list`, `show <id>`, `create`, `enable <id>`, `disable <id>` or `delete <id>`.');
|
|
74
86
|
case 'orders':
|
|
75
87
|
if (sub === 'test')
|
|
76
88
|
return cmd.ordersTest(flags, values);
|
package/dist/commands.js
CHANGED
|
@@ -10,6 +10,7 @@ import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from
|
|
|
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 { checkLink } from './links.js';
|
|
13
14
|
import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
|
|
14
15
|
import YAML from 'yaml';
|
|
15
16
|
import { storefrontCheck } from './check.js';
|
|
@@ -34,6 +35,12 @@ export const HELP = {
|
|
|
34
35
|
'promotions enable': 'Turn a promotion on: c11r promotions enable <id>.',
|
|
35
36
|
'promotions disable': 'Turn a promotion off without deleting it: c11r promotions disable <id>.',
|
|
36
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.',
|
|
37
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.',
|
|
38
45
|
'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
|
|
39
46
|
'orders show': 'Show one order: c11r orders show <id or number>.',
|
|
@@ -348,14 +355,18 @@ export async function claim() {
|
|
|
348
355
|
};
|
|
349
356
|
}
|
|
350
357
|
// ---------- promotions: live settings in Commerce Layer, not store files
|
|
351
|
-
/**
|
|
352
|
-
const READ_ONLY = ['status', 'uses', 'managed'];
|
|
353
|
-
/**
|
|
354
|
-
|
|
355
|
-
|
|
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 = '';
|
|
356
367
|
if (values.file) {
|
|
357
368
|
if (!fs.existsSync(values.file))
|
|
358
|
-
fail('missing_argument', `There is no file ${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.`);
|
|
359
370
|
text = fs.readFileSync(values.file, 'utf8');
|
|
360
371
|
}
|
|
361
372
|
else if (!process.stdin.isTTY) {
|
|
@@ -366,20 +377,24 @@ async function readSpec(values) {
|
|
|
366
377
|
text = Buffer.concat(chunks).toString('utf8');
|
|
367
378
|
}
|
|
368
379
|
else
|
|
369
|
-
fail('missing_argument',
|
|
380
|
+
fail('missing_argument', `No ${what} given.`, usage);
|
|
370
381
|
if (!text.trim())
|
|
371
|
-
fail('missing_argument',
|
|
382
|
+
fail('missing_argument', `No ${what} given: the input is empty.`, usage);
|
|
372
383
|
let raw;
|
|
373
384
|
try {
|
|
374
385
|
raw = YAML.parse(text);
|
|
375
386
|
}
|
|
376
387
|
catch (e) {
|
|
377
|
-
fail(
|
|
388
|
+
fail(`invalid_${what}`, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
|
|
378
389
|
}
|
|
379
|
-
// What show prints can be edited and sent back: the read-only fields are dropped.
|
|
380
390
|
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
381
391
|
for (const k of READ_ONLY)
|
|
382
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');
|
|
383
398
|
const store = loadStore();
|
|
384
399
|
return checkPromotion(raw, store.markets, store.products);
|
|
385
400
|
}
|
|
@@ -433,3 +448,43 @@ export async function promotionsDelete(id) {
|
|
|
433
448
|
await createApi(requireCredentials()).deletePromotion(id);
|
|
434
449
|
return { status: 'deleted', id };
|
|
435
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/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)) {
|
package/dist/storefront.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// top-level category), styles.css (theme tokens first, then the drop-in.css
|
|
4
4
|
// overrides, then the page) and app.js (market switch, category links, and a
|
|
5
5
|
// product page at #/p/<sku>). Product images are the store's, at /img/<file> (from
|
|
6
|
-
// .commercelayer/img/, uploaded by apply)
|
|
6
|
+
// .commercelayer/img/, uploaded by apply); a product without one shows the tile the
|
|
7
|
+
// store host draws at /tiles/<sku>.svg. The agent then makes it the human's.
|
|
7
8
|
import { isLocalImage } from './store.js';
|
|
8
9
|
/** img/<file> is the store's image at /img/<file> on the store host, whatever page links it. */
|
|
9
10
|
const imageSrc = (url) => (isLocalImage(url) ? `/${url}` : url);
|
|
11
|
+
/** A product without an image gets the tile the store host draws for it, at /tiles/<sku>.svg. */
|
|
12
|
+
const productImage = (p) => (p.image_url ? imageSrc(p.image_url) : `/tiles/${encodeURIComponent(p.sku)}.svg`);
|
|
10
13
|
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
11
14
|
/** The scaffold's files, by path relative to the storefront folder. */
|
|
12
15
|
export function storefrontFiles(store, clientId) {
|
|
@@ -17,7 +20,7 @@ function indexHtml(store, clientId) {
|
|
|
17
20
|
const card = (p, h) => {
|
|
18
21
|
const href = `#/p/${encodeURIComponent(p.sku)}`;
|
|
19
22
|
return ` <article class="product" id="p-${esc(p.sku)}">
|
|
20
|
-
<a class="media" href="${esc(href)}" tabindex="-1"
|
|
23
|
+
<a class="media" href="${esc(href)}" tabindex="-1"><img src="${esc(productImage(p))}" alt="${esc(p.name)}"${p.image_url ? '' : ' loading="lazy"'}></a>
|
|
21
24
|
<${h}><a href="${esc(href)}">${esc(p.name)}</a></${h}>
|
|
22
25
|
${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-price code="${esc(p.sku)}"><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
|
|
23
26
|
<cl-availability code="${esc(p.sku)}"><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
|
|
@@ -53,7 +56,7 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
|
|
|
53
56
|
}
|
|
54
57
|
const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
|
|
55
58
|
// What the product page shows, by sku. Prices and availability come live from drop-in.js.
|
|
56
|
-
const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url:
|
|
59
|
+
const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: productImage(p), category: p.category }]));
|
|
57
60
|
const json = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
|
|
58
61
|
return `<!doctype html>
|
|
59
62
|
<html lang="en">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
|
|
5
5
|
"homepage": "https://commercelayer.ai",
|
|
6
6
|
"keywords": [
|