@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 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 get a neutral tile. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
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 |
@@ -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 promotions = () => `${org()}/promotions`;
32
- const one = (id) => `${promotions()}/${encodeURIComponent(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('promotions_unsupported', 'This API does not manage promotions yet.', 'Try again later; tell the human if it persists.');
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 promotion ${id}.`, 'Run `c11r promotions list` for the ids, or `c11r promotions create` to make it.');
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(), spec)),
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
- const imageUrl = (ctx, url) => new URL(url, `${ctx.origin}/`).href;
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
- if (p.image_url)
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
- if (p.image_url)
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 };
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 card = (p) => `<article class="product"><a href="/products/${encodeURIComponent(p.sku)}${keep}">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<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>`;
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 = theme.hero ? `<section class="hero" style="background-image:url('${esc(img(theme.hero))}')">` : '<section class="hero band">';
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">${p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder"></div>'}<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>`;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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
+ }
@@ -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
- /** The fields list, show and the writes print, in a stable order. */
352
- const READ_ONLY = ['status', 'uses', 'managed'];
353
- /** The spec from -f <file> or stdin, YAML or JSON, validated against store.yml's markets and products. */
354
- async function readSpec(values) {
355
- let text = "";
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}.`, 'Give the promotion as YAML or JSON with -f <file>, or on stdin.');
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', 'No promotion given.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
380
+ fail('missing_argument', `No ${what} given.`, usage);
370
381
  if (!text.trim())
371
- fail('missing_argument', 'No promotion given: the input is empty.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
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('invalid_promotion', `promotion: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
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
+ }
@@ -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)) {
@@ -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). The agent then makes it the human's.
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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">${p.image_url ? `<img src="${esc(imageSrc(p.image_url))}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
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: p.image_url && imageSrc(p.image_url), category: p.category }]));
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",
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": [