@commercelayer/agent-cli 0.1.10 → 0.1.12
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 +3 -3
- package/dist/api/stub/shopping.js +4 -2
- package/dist/commands.js +42 -13
- package/dist/store.js +8 -0
- package/dist/storefront.js +211 -44
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
17
17
|
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, and the store's name and policies. |
|
|
18
18
|
| `apply [--allow-delete] [--full]` | Make the store match the files. 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. |
|
|
19
19
|
| `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. |
|
|
20
|
-
| `storefront init [--force]` | Write
|
|
21
|
-
| `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
|
|
20
|
+
| `storefront init [--force]` | 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. `img/`: product images (`image_url: img/<file>`); products with no image get a neutral tile. Refuses to overwrite without the flag; with it, rewrites the three files. |
|
|
21
|
+
| `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host, leaving out dotfiles. Lists products whose `image_url` points at a file that is not in the folder (`missing_images`). |
|
|
22
22
|
| `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 and every step. |
|
|
23
23
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
24
24
|
| `orders show <id or number>` | One order. |
|
|
@@ -70,7 +70,7 @@ COFFEE-ETH-250,"Ethiopia Yirgacheffe, 250 g","Washed, floral, bright.",Coffee >
|
|
|
70
70
|
COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
|
|
71
71
|
```
|
|
72
72
|
Errors name the file (and line, for CSV) and the sku, so one product file can be fixed without reading the rest. Moving a product between `store.yml` and a file is not a change to the store: `plan` keys everything by sku. The catalog has no stock: inventory is tracked live in Commerce Layer, and the sandbox treats every product as in stock. Prices in these files are what the store charges; a published storefront reads prices and availability live from Commerce Layer.
|
|
73
|
-
Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
|
|
73
|
+
Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; an `image_url` is an `https://` URL or a path in the storefront folder such as `img/eth.jpg` (served from the store host, given to shopping agents as a full URL); every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
|
|
74
74
|
### A catalog the human edits
|
|
75
75
|
When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
|
|
76
76
|
```yaml
|
|
@@ -86,6 +86,8 @@ 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
91
|
function variant(ctx, p, market) {
|
|
90
92
|
const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
|
|
91
93
|
const v = {
|
|
@@ -99,7 +101,7 @@ function variant(ctx, p, market) {
|
|
|
99
101
|
availability: { available: true, status: 'in_stock' },
|
|
100
102
|
};
|
|
101
103
|
if (p.image_url)
|
|
102
|
-
v.media = [{ type: 'image', url: p.image_url }];
|
|
104
|
+
v.media = [{ type: 'image', url: imageUrl(ctx, p.image_url) }];
|
|
103
105
|
if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
|
|
104
106
|
const o = shippingOption(market);
|
|
105
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 }] }] };
|
|
@@ -201,7 +203,7 @@ function buildLines(ctx, market, requested, existing = []) {
|
|
|
201
203
|
const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
|
|
202
204
|
const item = { id: p.sku, title: p.name, price };
|
|
203
205
|
if (p.image_url)
|
|
204
|
-
item.image_url = p.image_url;
|
|
206
|
+
item.image_url = imageUrl(ctx, p.image_url);
|
|
205
207
|
lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
|
|
206
208
|
});
|
|
207
209
|
return { lines, messages };
|
package/dist/commands.js
CHANGED
|
@@ -6,8 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import { createApi } from './api/client.js';
|
|
7
7
|
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
|
|
8
8
|
import { CliError, fail, progress } from './output.js';
|
|
9
|
-
import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
10
|
-
import {
|
|
9
|
+
import { diff, hashStore, isLocalImage, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
10
|
+
import { storefrontFiles } from './storefront.js';
|
|
11
11
|
import { pull } from './catalog.js';
|
|
12
12
|
import { buy } from './ucp/buy.js';
|
|
13
13
|
import { check } from './ucp/check.js';
|
|
@@ -20,7 +20,7 @@ export const HELP = {
|
|
|
20
20
|
plan: 'Show what apply would create, update or delete: markets, products and the store\'s name and policies, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
|
|
21
21
|
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
22
22
|
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
23
|
-
'storefront init': 'Write
|
|
23
|
+
'storefront init': 'Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js, plus img/ for product images. Refuses to overwrite unless --force is given.',
|
|
24
24
|
'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
|
|
25
25
|
'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.',
|
|
26
26
|
'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
|
|
@@ -164,15 +164,24 @@ export async function storefrontInit(flags) {
|
|
|
164
164
|
fail('storefront_exists', `${dir}/ already exists.`, 'Edit the files in it, or run `c11r storefront init --force` to regenerate them from store.yml (this overwrites your changes).');
|
|
165
165
|
}
|
|
166
166
|
const state = await api.getStore();
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
167
|
+
const written = [];
|
|
168
|
+
for (const [name, content] of Object.entries(storefrontFiles(desired, state.sales_channel_client_id))) {
|
|
169
|
+
const file = path.join(dir, name);
|
|
170
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
171
|
+
fs.writeFileSync(file, content);
|
|
172
|
+
written.push(file);
|
|
173
|
+
}
|
|
174
|
+
// img/ is where product images go (image_url: img/<file>); .gitkeep keeps the empty folder in git, and publish skips dotfiles.
|
|
175
|
+
const keep = path.join(dir, 'img', '.gitkeep');
|
|
176
|
+
fs.mkdirSync(path.dirname(keep), { recursive: true });
|
|
177
|
+
if (!fs.existsSync(keep))
|
|
178
|
+
fs.writeFileSync(keep, '');
|
|
170
179
|
return {
|
|
171
180
|
status: 'created',
|
|
172
|
-
files: [
|
|
181
|
+
files: [...written, keep],
|
|
173
182
|
markets: desired.markets.map((m) => m.code),
|
|
174
183
|
products: desired.products.length,
|
|
175
|
-
next: `Set storefront.path: ${dir} in ${paths.store}, make
|
|
184
|
+
next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; product images go in ${dir}/img/ with image_url: img/<file>; each product has a page at #/p/<sku>, built from #product-template in index.html), then run c11r storefront publish.`,
|
|
176
185
|
};
|
|
177
186
|
}
|
|
178
187
|
export async function storefrontPublish() {
|
|
@@ -181,16 +190,36 @@ export async function storefrontPublish() {
|
|
|
181
190
|
if (!fs.existsSync(path.join(dir, 'index.html'))) {
|
|
182
191
|
fail('no_storefront', `There is no ${path.join(dir, 'index.html')}.`, 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
|
|
183
192
|
}
|
|
193
|
+
// Dotfiles (.gitkeep, .DS_Store, .env) and dot folders stay on this machine.
|
|
184
194
|
const files = fs
|
|
185
195
|
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
186
196
|
.filter((e) => e.isFile())
|
|
187
|
-
.map((e) =>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
.map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
|
|
198
|
+
.filter((rel) => !rel.split('/').some((part) => part.startsWith('.')))
|
|
199
|
+
.map((rel) => ({ path: rel, content_base64: fs.readFileSync(path.join(dir, rel)).toString('base64') }));
|
|
200
|
+
// Products whose image_url is a path in the storefront that is not there.
|
|
201
|
+
let missingImages = [];
|
|
202
|
+
try {
|
|
203
|
+
const uploaded = new Set(files.map((f) => f.path));
|
|
204
|
+
missingImages = loadStore()
|
|
205
|
+
.products.filter((p) => p.image_url && isLocalImage(p.image_url) && !uploaded.has(p.image_url.replace(/^\.?\//, '')))
|
|
206
|
+
.map((p) => `${p.sku}: ${p.image_url}`);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (!(e instanceof CliError))
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
191
212
|
const published = await api.publishStorefront(dir, files);
|
|
192
213
|
const state = await api.getStore();
|
|
193
|
-
return {
|
|
214
|
+
return {
|
|
215
|
+
status: 'published',
|
|
216
|
+
preview_url: state.preview_url,
|
|
217
|
+
path: dir,
|
|
218
|
+
files: published.files,
|
|
219
|
+
published_at: published.published_at,
|
|
220
|
+
missing_images: missingImages.length ? missingImages : undefined,
|
|
221
|
+
next: missingImages.length ? `Add the missing images under ${dir}/ (image_url is relative to it), or fix image_url, then publish again.` : undefined,
|
|
222
|
+
};
|
|
194
223
|
}
|
|
195
224
|
/** The store's origin on the web: where it speaks UCP. */
|
|
196
225
|
async function storeOrigin() {
|
package/dist/store.js
CHANGED
|
@@ -153,6 +153,12 @@ export function checkProducts(sourced, currencies) {
|
|
|
153
153
|
const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
|
|
154
154
|
fail('invalid_product', `Product ${p.sku} in ${source} has ${unknown.length === 1 ? 'a field' : 'fields'} not in the catalog: ${unknown.join(', ')}.`, `${why}A product has ${PRODUCT_KEYS.join(', ')}. Remove the other fields.`);
|
|
155
155
|
}
|
|
156
|
+
if (p.image_url !== undefined) {
|
|
157
|
+
const url = String(p.image_url);
|
|
158
|
+
const ok = isLocalImage(url) ? !url.split('/').includes('..') && url.trim() === url && url !== '' : /^https?:\/\/\S+$/.test(url);
|
|
159
|
+
if (!ok)
|
|
160
|
+
fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${url}".`, 'Use a full https:// URL, or a path in the storefront folder such as img/eth.jpg (no "..").');
|
|
161
|
+
}
|
|
156
162
|
if (p.category !== undefined) {
|
|
157
163
|
const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
|
|
158
164
|
if (parts.some((c) => !c))
|
|
@@ -169,6 +175,8 @@ export function checkProducts(sourced, currencies) {
|
|
|
169
175
|
}
|
|
170
176
|
return products;
|
|
171
177
|
}
|
|
178
|
+
/** An image_url that is a path in the storefront (img/eth.jpg), not a URL. */
|
|
179
|
+
export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
|
|
172
180
|
export function toApplied(store) {
|
|
173
181
|
return {
|
|
174
182
|
markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
|
package/dist/storefront.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
// The storefront scaffold:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The storefront scaffold: a static site on drop-in.js, no build step. Three files:
|
|
2
|
+
// index.html (the config block, the drop-in elements, the products grouped by
|
|
3
|
+
// top-level category), styles.css (theme tokens first, then the drop-in.css
|
|
4
|
+
// overrides, then the page) and app.js (market switch, category links, and a
|
|
5
|
+
// product page at #/p/<sku>). The agent then makes it the human's.
|
|
4
6
|
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
5
|
-
|
|
7
|
+
/** The scaffold's files, by path relative to the storefront folder. */
|
|
8
|
+
export function storefrontFiles(store, clientId) {
|
|
9
|
+
return { 'index.html': indexHtml(store, clientId), 'styles.css': STYLES, 'app.js': APP };
|
|
10
|
+
}
|
|
11
|
+
function indexHtml(store, clientId) {
|
|
6
12
|
const name = esc(store.name);
|
|
7
|
-
const card = (p, h) =>
|
|
8
|
-
|
|
13
|
+
const card = (p, h) => {
|
|
14
|
+
const href = `#/p/${encodeURIComponent(p.sku)}`;
|
|
15
|
+
return ` <article class="product" id="p-${esc(p.sku)}">
|
|
16
|
+
<a class="media" href="${esc(href)}" tabindex="-1">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}">` : '<div class="placeholder" aria-hidden="true"></div>'}</a>
|
|
17
|
+
<${h}><a href="${esc(href)}">${esc(p.name)}</a></${h}>
|
|
9
18
|
${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>
|
|
10
19
|
<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>
|
|
11
20
|
<cl-add-to-cart code="${esc(p.sku)}">Add to cart</cl-add-to-cart>
|
|
12
21
|
</article>`;
|
|
22
|
+
};
|
|
13
23
|
// Products are grouped by the top level of their category, in order of first appearance;
|
|
14
24
|
// products with no category come last, under "Other". No categories: one grid.
|
|
15
25
|
const groups = new Map();
|
|
@@ -17,11 +27,10 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
|
|
|
17
27
|
const top = p.category?.split(' > ')[0] ?? '';
|
|
18
28
|
groups.set(top, [...(groups.get(top) ?? []), p]);
|
|
19
29
|
}
|
|
20
|
-
|
|
21
|
-
let body;
|
|
30
|
+
let listing;
|
|
22
31
|
let categoryNav = '';
|
|
23
|
-
if (!
|
|
24
|
-
|
|
32
|
+
if (![...groups.keys()].some((k) => k))
|
|
33
|
+
listing = ` <div class="grid">\n${store.products.map((p) => card(p, 'h2')).join('\n')}\n </div>`;
|
|
25
34
|
else {
|
|
26
35
|
const ordered = [...groups.keys()].filter((k) => k).concat(groups.has('') ? [''] : []);
|
|
27
36
|
const used = new Set();
|
|
@@ -34,11 +43,14 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
|
|
|
34
43
|
return id;
|
|
35
44
|
});
|
|
36
45
|
categoryNav = ` <nav class="categories">\n${ordered.map((k, i) => ` <a href="#${ids[i]}">${esc(k || 'Other')}</a>`).join('\n')}\n </nav>\n`;
|
|
37
|
-
|
|
38
|
-
.map((k, i) => `
|
|
46
|
+
listing = ordered
|
|
47
|
+
.map((k, i) => ` <section id="${ids[i]}">\n <h2>${esc(k || 'Other')}</h2>\n <div class="grid">\n${groups.get(k).map((p) => card(p, 'h3').replace(/^/gm, ' ')).join('\n')}\n </div>\n </section>`)
|
|
39
48
|
.join('\n');
|
|
40
49
|
}
|
|
41
50
|
const options = store.markets.map((m) => ` <option value="${esc(m.code)}">${esc(m.name)} (${esc(m.currency)})</option>`).join('\n');
|
|
51
|
+
// What the product page shows, by sku. Prices and availability come live from drop-in.js.
|
|
52
|
+
const data = Object.fromEntries(store.products.map((p) => [p.sku, { name: p.name, description: p.description, image_url: p.image_url, category: p.category }]));
|
|
53
|
+
const json = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
|
|
42
54
|
return `<!doctype html>
|
|
43
55
|
<html lang="en">
|
|
44
56
|
<head>
|
|
@@ -48,6 +60,8 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
|
|
|
48
60
|
<!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout. -->
|
|
49
61
|
<link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">
|
|
50
62
|
<link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/minicart.css" rel="stylesheet">
|
|
63
|
+
<!-- After the drop-in.js stylesheets: styles.css overrides them with the same selectors. Restyle through its tokens. -->
|
|
64
|
+
<link href="styles.css" rel="stylesheet">
|
|
51
65
|
<script>
|
|
52
66
|
// The market comes from ?market=<code>; the first market in store.yml is the default. Keep clientId as it is.
|
|
53
67
|
var market = new URLSearchParams(location.search).get('market') || '${esc(store.markets[0].code)}';
|
|
@@ -58,40 +72,14 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
|
|
|
58
72
|
};
|
|
59
73
|
</script>
|
|
60
74
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"></script>
|
|
61
|
-
<
|
|
62
|
-
:root { color-scheme: light; --ink: #16181d; --muted: #5d6572; --line: #e3e6ea; --accent: #1f5eff; }
|
|
63
|
-
body { margin: 0; font-family: system-ui, sans-serif; color: var(--ink); background: #fff; }
|
|
64
|
-
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); }
|
|
65
|
-
header h1 { margin: 0; font-size: 20px; }
|
|
66
|
-
header nav { display: flex; align-items: center; gap: 16px; }
|
|
67
|
-
nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
|
|
68
|
-
nav.categories a { color: var(--ink); text-decoration: none; font-size: 15px; }
|
|
69
|
-
nav.categories a:hover { color: var(--accent); }
|
|
70
|
-
main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
|
|
71
|
-
main section { scroll-margin-top: 16px; }
|
|
72
|
-
main section + section { margin-top: 40px; }
|
|
73
|
-
main section > h2 { margin: 0 0 16px; font-size: 22px; }
|
|
74
|
-
.grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
|
75
|
-
.product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; }
|
|
76
|
-
.product img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; }
|
|
77
|
-
.product h2, .product h3 { margin: 0; font-size: 17px; }
|
|
78
|
-
.product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
|
79
|
-
cl-price { font-weight: 600; }
|
|
80
|
-
cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; text-decoration: line-through; }
|
|
81
|
-
cl-availability { font-size: 13px; color: var(--muted); }
|
|
82
|
-
cl-add-to-cart { margin-top: auto; }
|
|
83
|
-
/* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
|
|
84
|
-
cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
|
|
85
|
-
cl-add-to-cart::part(button), cl-checkout-link::part(a) { display: inline-block; padding: 10px 16px; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; border: 0; cursor: pointer; text-decoration: none; }
|
|
86
|
-
footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
|
87
|
-
</style>
|
|
75
|
+
<script src="app.js" defer></script>
|
|
88
76
|
</head>
|
|
89
77
|
<body>
|
|
90
78
|
<header>
|
|
91
|
-
<h1>${name}</h1>
|
|
79
|
+
<h1><a href="#">${name}</a></h1>
|
|
92
80
|
<nav>
|
|
93
|
-
<label>Market
|
|
94
|
-
<select
|
|
81
|
+
<label><span class="market-label">Market</span>
|
|
82
|
+
<select id="market">
|
|
95
83
|
${options}
|
|
96
84
|
</select>
|
|
97
85
|
</label>
|
|
@@ -100,11 +88,190 @@ ${options}
|
|
|
100
88
|
</nav>
|
|
101
89
|
</header>
|
|
102
90
|
${categoryNav} <main>
|
|
103
|
-
|
|
91
|
+
<div id="listing">
|
|
92
|
+
${listing}
|
|
93
|
+
</div>
|
|
94
|
+
<!-- The product page: app.js fills it from #product-template and #products when the address is #/p/<sku>. -->
|
|
95
|
+
<div id="product-page" hidden></div>
|
|
104
96
|
</main>
|
|
105
97
|
<footer>Sandbox store: test payments only.</footer>
|
|
106
|
-
|
|
98
|
+
|
|
99
|
+
<!-- The product page's markup. Elements with data-field get the product's data; elements with data-code get code="<sku>" for drop-in.js. -->
|
|
100
|
+
<template id="product-template">
|
|
101
|
+
<article class="product-page">
|
|
102
|
+
<a class="back" href="#">← All products</a>
|
|
103
|
+
<div class="media"><img data-field="image_url" alt=""><div class="placeholder" aria-hidden="true"></div></div>
|
|
104
|
+
<div class="info">
|
|
105
|
+
<p class="category" data-field="category"></p>
|
|
106
|
+
<h2 data-field="name"></h2>
|
|
107
|
+
<p data-field="description"></p>
|
|
108
|
+
<cl-price data-code><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
|
|
109
|
+
<cl-availability data-code><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
|
|
110
|
+
<cl-add-to-cart data-code>Add to cart</cl-add-to-cart>
|
|
111
|
+
</div>
|
|
112
|
+
</article>
|
|
113
|
+
</template>
|
|
114
|
+
<template id="not-found-template">
|
|
115
|
+
<article class="product-page">
|
|
116
|
+
<a class="back" href="#">← All products</a>
|
|
117
|
+
<p>This product is not in the store.</p>
|
|
118
|
+
</article>
|
|
119
|
+
</template>
|
|
120
|
+
<!-- The products, by sku: name, description, image_url, category. Keep it in step with the cards above. -->
|
|
121
|
+
<script type="application/json" id="products">
|
|
122
|
+
${json}
|
|
123
|
+
</script>
|
|
107
124
|
</body>
|
|
108
125
|
</html>
|
|
109
126
|
`;
|
|
110
127
|
}
|
|
128
|
+
const STYLES = `/* The storefront's styles, in three parts: theme tokens, drop-in.js overrides, the page.
|
|
129
|
+
Restyle the page by changing the tokens; a dark theme is color-scheme: dark with paper and ink swapped.
|
|
130
|
+
index.html links this file after the drop-in.js stylesheets: the overrides use the same selectors as
|
|
131
|
+
drop-in.css, so they win by coming later. Keep that order. */
|
|
132
|
+
|
|
133
|
+
/* 1. Theme tokens */
|
|
134
|
+
:root {
|
|
135
|
+
color-scheme: light;
|
|
136
|
+
--paper: #ffffff; /* page background */
|
|
137
|
+
--ink: #16181d; /* text and prices */
|
|
138
|
+
--muted: #5d6572; /* secondary text, compare-at prices, availability */
|
|
139
|
+
--line: #e3e6ea; /* borders, disabled buttons */
|
|
140
|
+
--accent: #1f5eff; /* buttons, link hover */
|
|
141
|
+
--on-accent: #ffffff; /* text on the accent */
|
|
142
|
+
--radius: 8px;
|
|
143
|
+
--font: system-ui, sans-serif;
|
|
144
|
+
/* drop-in.css's own defaults (add to cart, cart count) read these. */
|
|
145
|
+
--cl-color-primary: var(--accent);
|
|
146
|
+
--cl-color-white: var(--on-accent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* 2. drop-in.js overrides */
|
|
150
|
+
/* drop-in.css colours cl-price-amount itself (#101111), so inheriting is not enough: set it here. */
|
|
151
|
+
cl-price-amount { color: var(--ink); font-weight: 600; }
|
|
152
|
+
cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; }
|
|
153
|
+
cl-availability { font-size: 13px; color: var(--muted); }
|
|
154
|
+
/* cl-add-to-cart is the button itself (it has no ::part); drop-in.css colours it with these same selectors. */
|
|
155
|
+
cl-add-to-cart { align-self: flex-start; padding: 10px 16px; border-radius: calc(var(--radius) - 2px); font: inherit; font-weight: 600; background-color: var(--line); color: var(--muted); }
|
|
156
|
+
cl-add-to-cart:not([aria-disabled=true]) { background-color: var(--accent); color: var(--on-accent); }
|
|
157
|
+
/* cl-checkout-link renders a plain <a> (no shadow DOM); without an order it is aria-disabled. */
|
|
158
|
+
cl-checkout-link a { display: inline-block; padding: 8px 14px; border-radius: calc(var(--radius) - 2px); background: var(--accent); color: var(--on-accent); font-weight: 600; text-decoration: none; }
|
|
159
|
+
cl-checkout-link[aria-disabled="true"] a { background: var(--line); color: var(--muted); }
|
|
160
|
+
/* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
|
|
161
|
+
cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
|
|
162
|
+
|
|
163
|
+
/* 3. The page */
|
|
164
|
+
body { margin: 0; font-family: var(--font); color: var(--ink); background: var(--paper); }
|
|
165
|
+
a { color: inherit; }
|
|
166
|
+
header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 16px 24px; border-bottom: 1px solid var(--line); }
|
|
167
|
+
header h1 { margin: 0; font-size: 20px; }
|
|
168
|
+
header h1 a { text-decoration: none; }
|
|
169
|
+
/* The nav never wraps inside: on a narrow screen it moves under the store name as a whole. */
|
|
170
|
+
header nav { display: flex; align-items: center; gap: 16px; margin-left: auto; white-space: nowrap; }
|
|
171
|
+
header select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: calc(var(--radius) / 2); padding: 4px 6px; }
|
|
172
|
+
nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
|
|
173
|
+
nav.categories a { text-decoration: none; font-size: 15px; }
|
|
174
|
+
nav.categories a:hover { color: var(--accent); }
|
|
175
|
+
main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
|
|
176
|
+
main section { scroll-margin-top: 16px; }
|
|
177
|
+
main section + section { margin-top: 40px; }
|
|
178
|
+
main section > h2 { margin: 0 0 16px; font-size: 22px; }
|
|
179
|
+
.grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
|
180
|
+
.product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: var(--radius); scroll-margin-top: 16px; }
|
|
181
|
+
.product h2, .product h3 { margin: 0; font-size: 17px; }
|
|
182
|
+
.product h2 a, .product h3 a { text-decoration: none; }
|
|
183
|
+
.product h2 a:hover, .product h3 a:hover { color: var(--accent); }
|
|
184
|
+
.product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
|
185
|
+
.product cl-add-to-cart { margin-top: auto; }
|
|
186
|
+
.media img, .media .placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: calc(var(--radius) - 2px); }
|
|
187
|
+
/* No image: a plain tile a shade off the page, in any theme. */
|
|
188
|
+
.placeholder { background: color-mix(in srgb, var(--ink) 6%, var(--paper)); }
|
|
189
|
+
.product-page { display: grid; gap: 32px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
|
|
190
|
+
.product-page .back { grid-column: 1 / -1; text-decoration: none; color: var(--muted); }
|
|
191
|
+
.product-page .back:hover { color: var(--accent); }
|
|
192
|
+
.product-page .info { display: flex; flex-direction: column; gap: 12px; }
|
|
193
|
+
.product-page .info h2 { margin: 0; font-size: 28px; }
|
|
194
|
+
.product-page .info p { margin: 0; line-height: 1.6; }
|
|
195
|
+
.product-page .category { color: var(--muted); font-size: 14px; }
|
|
196
|
+
footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
|
197
|
+
@media (max-width: 720px) {
|
|
198
|
+
.product-page { grid-template-columns: 1fr; }
|
|
199
|
+
}
|
|
200
|
+
@media (max-width: 480px) {
|
|
201
|
+
header, nav.categories { padding-left: 16px; padding-right: 16px; }
|
|
202
|
+
header nav { gap: 12px; }
|
|
203
|
+
.market-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
204
|
+
main { padding: 24px 16px; }
|
|
205
|
+
}
|
|
206
|
+
`;
|
|
207
|
+
const APP = `// The storefront's behaviour, no build step: the market switch, the category links,
|
|
208
|
+
// and the product page at #/p/<sku>, filled from #product-template and the #products data.
|
|
209
|
+
// drop-in.js does the commerce: prices, availability, cart, checkout.
|
|
210
|
+
|
|
211
|
+
(function () {
|
|
212
|
+
var products = JSON.parse(document.getElementById('products').textContent);
|
|
213
|
+
var listing = document.getElementById('listing');
|
|
214
|
+
var page = document.getElementById('product-page');
|
|
215
|
+
var storeTitle = document.title;
|
|
216
|
+
|
|
217
|
+
// Market: ?market=<code> picks the drop-in.js scope (see the config block in index.html).
|
|
218
|
+
var select = document.getElementById('market');
|
|
219
|
+
select.value = market;
|
|
220
|
+
select.addEventListener('change', function () {
|
|
221
|
+
location.search = '?market=' + encodeURIComponent(select.value);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function fill(root, product, sku) {
|
|
225
|
+
root.querySelectorAll('[data-field]').forEach(function (el) {
|
|
226
|
+
var value = product[el.dataset.field];
|
|
227
|
+
if (el.tagName === 'IMG') {
|
|
228
|
+
if (!value) return el.remove();
|
|
229
|
+
el.src = value;
|
|
230
|
+
el.alt = product.name;
|
|
231
|
+
if (el.nextElementSibling) el.nextElementSibling.remove();
|
|
232
|
+
} else if (value) el.textContent = value;
|
|
233
|
+
else el.remove();
|
|
234
|
+
});
|
|
235
|
+
// code is set before the elements join the page, so drop-in.js reads it when they connect.
|
|
236
|
+
root.querySelectorAll('[data-code]').forEach(function (el) {
|
|
237
|
+
el.setAttribute('code', sku);
|
|
238
|
+
el.removeAttribute('data-code');
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function showProduct(sku) {
|
|
243
|
+
var product = products[sku];
|
|
244
|
+
var view = document.getElementById(product ? 'product-template' : 'not-found-template').content.cloneNode(true);
|
|
245
|
+
if (product) fill(view, product, sku);
|
|
246
|
+
page.replaceChildren(view);
|
|
247
|
+
listing.hidden = true;
|
|
248
|
+
page.hidden = false;
|
|
249
|
+
document.title = (product ? product.name + ' · ' : '') + storeTitle;
|
|
250
|
+
window.scrollTo(0, 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function showListing(anchor) {
|
|
254
|
+
page.hidden = true;
|
|
255
|
+
page.replaceChildren();
|
|
256
|
+
listing.hidden = false;
|
|
257
|
+
document.title = storeTitle;
|
|
258
|
+
var target = anchor && document.getElementById(anchor);
|
|
259
|
+
if (target) target.scrollIntoView();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
var lastSku = null;
|
|
263
|
+
function route() {
|
|
264
|
+
var hash = decodeURIComponent(location.hash.slice(1));
|
|
265
|
+
if (hash.indexOf('/p/') === 0) {
|
|
266
|
+
lastSku = hash.slice(3);
|
|
267
|
+
return showProduct(lastSku);
|
|
268
|
+
}
|
|
269
|
+
// A category link (#category-...), or the listing (#): back from a product, return to its card.
|
|
270
|
+
showListing(hash || (lastSku && 'p-' + lastSku));
|
|
271
|
+
lastSku = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
window.addEventListener('hashchange', route);
|
|
275
|
+
route();
|
|
276
|
+
})();
|
|
277
|
+
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22.0.0",
|
|
38
|
+
"happy-dom": "^21.0.4",
|
|
38
39
|
"tsx": "^4.19.0",
|
|
39
40
|
"typescript": "^5.6.0",
|
|
40
41
|
"vitest": "^3.0.0"
|