@commercelayer/agent-cli 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -19
- package/dist/api/client.js +39 -1
- package/dist/api/stub/management.js +169 -29
- package/dist/api/stub/shopping.js +5 -6
- package/dist/api/stub/theme.js +32 -3
- package/dist/api/stub/ucp.js +4 -1
- package/dist/bin.js +31 -1
- package/dist/commands.js +160 -27
- package/dist/files.js +21 -0
- package/dist/links.js +118 -0
- package/dist/promotions.js +59 -30
- package/dist/store.js +3 -29
- package/dist/storefront.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,13 +14,19 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
14
14
|
## Commands
|
|
15
15
|
| Command | Does |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials
|
|
18
|
-
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products,
|
|
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
|
|
17
|
+
| `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`, plus a short `AGENTS.md` and `CLAUDE.md` section (appended to an existing file, never twice) that sends later agents in the repo to [commercelayer.ai/skills.md](https://commercelayer.ai/skills.md). Reuses the store if they exist. |
|
|
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
|
+
| `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
|
+
| `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
|
+
| `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
|
+
| `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). |
|
|
24
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. |
|
|
25
31
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
26
32
|
| `orders show <id or number>` | One order. |
|
|
@@ -58,16 +64,6 @@ theme:
|
|
|
58
64
|
logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
|
|
59
65
|
hero: img/hero.jpg # the home page's wide image; default: a band in the accent
|
|
60
66
|
tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
|
|
61
|
-
promotions:
|
|
62
|
-
# Discounts: rules (when, all must match) and actions (what). The id is stable.
|
|
63
|
-
- id: welcome10
|
|
64
|
-
name: Welcome, 10% off
|
|
65
|
-
rules: [{ coupon: WELCOME10 }]
|
|
66
|
-
actions: [{ percentage: 10 }]
|
|
67
|
-
- id: free-shipping-50
|
|
68
|
-
market: eu
|
|
69
|
-
rules: [{ order_amount: { min: 50 } }]
|
|
70
|
-
actions: [free_shipping]
|
|
71
67
|
policies:
|
|
72
68
|
# Pages shopping agents show buyers before they pay. Without them the store
|
|
73
69
|
# serves default sandbox pages at <preview_url>/policies/<name>.
|
|
@@ -76,13 +72,33 @@ policies:
|
|
|
76
72
|
returns: https://example.com/returns
|
|
77
73
|
```
|
|
78
74
|
### Promotions
|
|
79
|
-
|
|
75
|
+
Promotions are live settings in Commerce Layer, not store files: the `promotions` commands create and change them directly, and nothing about them is committed. A spec, in YAML or JSON on stdin or with `-f <file>`:
|
|
80
76
|
|
|
81
|
-
|
|
77
|
+
```yaml
|
|
78
|
+
id: welcome10 # the handle: lowercase letters, digits, dashes; unique
|
|
79
|
+
name: Welcome, 10% off
|
|
80
|
+
rules: [{ coupon: WELCOME10 }]
|
|
81
|
+
actions: [{ percentage: 10 }]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Optional: `market` (a market code; without it, every market), `starts` and `ends` (a day, or a UTC time; a bare `ends` day runs to 23:59:59Z; without them the promotion is always on), `limit` (total uses), `priority` (lowest first), `exclusive`, `enabled`.
|
|
85
|
+
|
|
86
|
+
- Rules, one of each kind at most, all matching: `coupon: CODE` or a list (8 to 40 letters, digits, `-`, `_`), `order_amount: { min }` (the items total), `skus: [...]` or `category: A > B`. A category becomes the skus of its products in store.yml when the command runs: after adding products to it, run `update` again.
|
|
82
87
|
- Actions: `percentage: 10` (with `skus` or `category` to limit it), `fixed_amount: 5`, `free_shipping`, `fixed_price: { price, skus|category }`, `free_gift: { skus|category, quantity }`, `buy_x_pay_y: { x, y, cheapest_free?, skus|category }`. Actions without their own skus take the rule's. Amounts are in major units, like prices; in a store with several currencies, a promotion with an amount names its `market`. Commerce Layer gets one promotion per action.
|
|
83
|
-
- `flex: { rules: [...] }` holds a Commerce Layer Rules Engine promotion as written (amounts in cents), in place of `rules` and `actions`.
|
|
88
|
+
- `flex: { rules: [...] }` holds a Commerce Layer Rules Engine promotion as written (amounts in cents), in place of `rules` and `actions`. Sandboxes have no Rules Engine: `create` answers `flex_unavailable` and creates nothing.
|
|
89
|
+
|
|
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`.
|
|
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
|
+
```
|
|
84
100
|
|
|
85
|
-
`
|
|
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.
|
|
86
102
|
|
|
87
103
|
### The products folder
|
|
88
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:
|
|
@@ -115,7 +131,7 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
115
131
|
|
|
116
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/`.
|
|
117
133
|
|
|
118
|
-
Error codes: `invalid_theme`, `invalid_promotion`, `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`.
|
|
119
135
|
## The store speaks UCP
|
|
120
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:
|
|
121
137
|
| What | Where |
|
package/dist/api/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The client for the management API at api.commercelayer.ai.
|
|
2
|
-
import { fail } from '../output.js';
|
|
2
|
+
import { CliError, fail } from '../output.js';
|
|
3
3
|
import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
|
|
4
4
|
export function createApi(auth) {
|
|
5
5
|
const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
|
|
@@ -27,7 +27,45 @@ 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 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
|
+
try {
|
|
35
|
+
return await call;
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof CliError && (e.code === 'not_found' || (e.code === 'api_error' && /\b404\b/.test(e.message))))
|
|
39
|
+
fail(`${kind}_unsupported`, `This API does not manage ${kind} yet.`, 'Try again later; tell the human if it persists.');
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const found = async (kind, id, call) => {
|
|
44
|
+
try {
|
|
45
|
+
return await call;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
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
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
30
53
|
return {
|
|
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
|
+
deletePromotion: async (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)));
|
|
68
|
+
},
|
|
31
69
|
createSandbox: (name) => request('POST', '/sandboxes', { name }),
|
|
32
70
|
getStore: () => request('GET', org()),
|
|
33
71
|
applyStore: (body) => request('PUT', `${org()}/resources`, body),
|
|
@@ -7,11 +7,10 @@ import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
|
7
7
|
import { bearer, error, json, readJson } from './http.js';
|
|
8
8
|
function publicState(r) {
|
|
9
9
|
const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
|
|
10
|
-
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {},
|
|
10
|
+
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
|
|
11
11
|
}
|
|
12
|
-
/** Like Commerce Layer refusing a sku
|
|
12
|
+
/** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
|
|
13
13
|
const REFUSED = /^REFUSED-/;
|
|
14
|
-
const REFUSED_PROMOTION = /^refused-/;
|
|
15
14
|
/** C11R_STUB_BUSY_READS=n: the first n reads of a running import answer 503 commercelayer_busy, as when Commerce Layer rate-limits. Per process. */
|
|
16
15
|
let busyReads = Number(process.env.C11R_STUB_BUSY_READS) || 0;
|
|
17
16
|
/** Items an import gets through per read: small, so the CLI polls a few times. */
|
|
@@ -29,23 +28,83 @@ function advance(r) {
|
|
|
29
28
|
return;
|
|
30
29
|
job.step = 'done';
|
|
31
30
|
job.finished_at = new Date().toISOString();
|
|
32
|
-
|
|
33
|
-
if (r.pending.refused.length || refusedPromotions.length) {
|
|
31
|
+
if (r.pending.refused.length) {
|
|
34
32
|
job.status = 'failed';
|
|
35
|
-
job.errors =
|
|
36
|
-
...r.pending.refused.map((sku) => ({ sku, message: 'code is invalid' })),
|
|
37
|
-
...refusedPromotions.map((id) => ({ sku: null, promotion: id, message: 'is invalid' })),
|
|
38
|
-
].slice(0, 50);
|
|
33
|
+
job.errors = r.pending.refused.slice(0, 50).map((sku) => ({ sku, message: 'code is invalid' }));
|
|
39
34
|
}
|
|
40
35
|
else {
|
|
41
36
|
job.status = 'completed';
|
|
42
37
|
r.applied = r.pending.applied;
|
|
43
|
-
if (r.pending.promotions)
|
|
44
|
-
r.promotions = r.pending.promotions;
|
|
45
38
|
r.applied_hash = job.hash;
|
|
46
39
|
}
|
|
47
40
|
delete r.pending;
|
|
48
41
|
}
|
|
42
|
+
function withDefaults(spec) {
|
|
43
|
+
return { ...spec, starts: spec.starts ?? '2000-01-01T00:00:00Z', ends: spec.ends ?? '2100-01-01T00:00:00Z', exclusive: spec.exclusive ?? false, enabled: spec.enabled ?? true };
|
|
44
|
+
}
|
|
45
|
+
/** As the API lists it: the spec plus status and uses. */
|
|
46
|
+
function listed(p) {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const status = !p.enabled ? 'disabled' : p.limit !== undefined && p.uses >= p.limit ? 'used_up' : Date.parse(p.ends) <= now ? 'expired' : Date.parse(p.starts) > now ? 'scheduled' : 'active';
|
|
49
|
+
return { ...p, status, managed: true };
|
|
50
|
+
}
|
|
51
|
+
/** The API checks skus against the last applied catalog. */
|
|
52
|
+
function unknownSkus(r, spec) {
|
|
53
|
+
const known = r.applied?.products ?? {};
|
|
54
|
+
const where = [['rules.skus', spec.rules?.skus], ...(spec.actions ?? []).map((a, i) => [`actions[${i}].skus`, 'skus' in a ? a.skus : undefined])];
|
|
55
|
+
for (const [path, list] of where) {
|
|
56
|
+
const i = (list ?? []).findIndex((sku) => !(sku in known));
|
|
57
|
+
if (i >= 0)
|
|
58
|
+
return error(422, 'invalid_promotion', `${path}[${i}]: ${list[i]} is not a product of the store.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
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
|
+
}
|
|
49
108
|
export function handleManagement(req) {
|
|
50
109
|
const db = readDb();
|
|
51
110
|
const path = req.url.pathname;
|
|
@@ -105,21 +164,11 @@ export function handleManagement(req) {
|
|
|
105
164
|
writeDb(db);
|
|
106
165
|
if (record.apply?.status === 'in_progress')
|
|
107
166
|
return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
|
|
108
|
-
const { applied, hash, name, policies, theme,
|
|
167
|
+
const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
|
|
109
168
|
const before = record.applied?.products ?? {};
|
|
110
169
|
const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const promos = promotions ? Object.fromEntries(promotions.map((p) => [p.id, p])) : promosBefore;
|
|
114
|
-
const promosRemoved = Object.keys(promosBefore).filter((id) => !(id in promos));
|
|
115
|
-
const promosChanged = Object.keys(promos).filter((id) => !(id in promosBefore) || canonical(promosBefore[id]) !== canonical(promos[id]));
|
|
116
|
-
const doomed = [...removed.map((sku) => `product ${sku}`), ...promosRemoved.map((id) => `promotion ${id}`)];
|
|
117
|
-
if (doomed.length && !allow_delete)
|
|
118
|
-
return error(409, 'deletes_need_approval', `Apply would delete ${doomed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
|
|
119
|
-
// Sandboxes have no Rules Engine: a new or changed flex promotion is refused before anything applies.
|
|
120
|
-
const flexAt = (promotions ?? []).findIndex((p) => p.flex && promosChanged.includes(p.id));
|
|
121
|
-
if (flexAt >= 0)
|
|
122
|
-
return error(422, 'flex_unavailable', `promotions[${flexAt}] (${promotions[flexAt].id}): flex promotions need the Rules Engine, which this organization does not have.`, 'Tell the human flex promotions need a Commerce Layer plan with the Rules Engine; build the promotion from the standard rules and actions if you can, or remove it, then apply again.');
|
|
170
|
+
if (removed.length && !allow_delete)
|
|
171
|
+
return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
|
|
123
172
|
// The name, policies and markets apply in the request; products go through an
|
|
124
173
|
// import job (SKUs, prices, then cleanups for removals) when any changed.
|
|
125
174
|
record.name = name;
|
|
@@ -136,10 +185,8 @@ export function handleManagement(req) {
|
|
|
136
185
|
}
|
|
137
186
|
}
|
|
138
187
|
const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
|
|
139
|
-
|
|
140
|
-
if (changed.length === 0 && removed.length === 0 && refusedPromotions.length === 0) {
|
|
188
|
+
if (changed.length === 0 && removed.length === 0) {
|
|
141
189
|
record.applied = applied;
|
|
142
|
-
record.promotions = promos;
|
|
143
190
|
record.applied_hash = hash;
|
|
144
191
|
writeDb(db);
|
|
145
192
|
return json(200, publicState(record));
|
|
@@ -150,15 +197,108 @@ export function handleManagement(req) {
|
|
|
150
197
|
step: 'skus',
|
|
151
198
|
hash,
|
|
152
199
|
processed: 0,
|
|
153
|
-
total: changed.length + removed.length
|
|
200
|
+
total: changed.length + removed.length,
|
|
154
201
|
started_at: new Date().toISOString(),
|
|
155
202
|
finished_at: null,
|
|
156
203
|
errors: [],
|
|
157
204
|
};
|
|
158
|
-
record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku))
|
|
205
|
+
record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)) };
|
|
159
206
|
writeDb(db);
|
|
160
207
|
return json(202, publicState(record));
|
|
161
208
|
}
|
|
209
|
+
// Promotions: live, synchronous, keyed by id. Sandboxes have no Rules Engine.
|
|
210
|
+
if (rest === '/promotions' || rest.startsWith('/promotions/')) {
|
|
211
|
+
const store = (record.promotions ??= {});
|
|
212
|
+
const [, , rawId, verb] = rest.split('/');
|
|
213
|
+
const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
|
|
214
|
+
const save = () => writeDb(db);
|
|
215
|
+
const flexRefused = (spec) => (spec.flex ? error(422, 'flex_unavailable', 'Flex promotions need the Rules Engine, which this organization does not have.', 'Tell the human flex promotions need a Commerce Layer plan with the Rules Engine; build the promotion from the standard rules and actions if you can.') : undefined);
|
|
216
|
+
if (id === undefined) {
|
|
217
|
+
if (req.method === 'GET')
|
|
218
|
+
return json(200, { promotions: Object.keys(store).sort().map((k) => listed(store[k])) });
|
|
219
|
+
if (req.method === 'POST') {
|
|
220
|
+
const spec = readJson(req);
|
|
221
|
+
if (store[spec.id])
|
|
222
|
+
return error(409, 'promotion_exists', `There is already a promotion ${spec.id}.`, 'Use `c11r promotions update <id>` to change it, or pick another id.');
|
|
223
|
+
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
224
|
+
if (refused)
|
|
225
|
+
return refused;
|
|
226
|
+
store[spec.id] = { ...withDefaults(spec), uses: 0 };
|
|
227
|
+
save();
|
|
228
|
+
return json(201, listed(store[spec.id]));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const p = store[id];
|
|
233
|
+
if (!p)
|
|
234
|
+
return error(404, 'not_found', `There is no promotion ${id}.`, 'Run `c11r promotions list`.');
|
|
235
|
+
if (verb === undefined && req.method === 'GET')
|
|
236
|
+
return json(200, listed(p));
|
|
237
|
+
if (verb === undefined && req.method === 'PUT') {
|
|
238
|
+
const spec = readJson(req);
|
|
239
|
+
if (spec.id !== id)
|
|
240
|
+
return error(422, 'invalid_promotion', `id: the body has ${spec.id}, the path ${id}.`, 'An id never changes.');
|
|
241
|
+
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
242
|
+
if (refused)
|
|
243
|
+
return refused;
|
|
244
|
+
store[id] = { ...withDefaults(spec), uses: p.uses };
|
|
245
|
+
save();
|
|
246
|
+
return json(200, listed(store[id]));
|
|
247
|
+
}
|
|
248
|
+
if (verb === undefined && req.method === 'DELETE') {
|
|
249
|
+
delete store[id];
|
|
250
|
+
save();
|
|
251
|
+
return { status: 204, headers: {}, body: '' };
|
|
252
|
+
}
|
|
253
|
+
if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
|
|
254
|
+
p.enabled = verb === 'enable';
|
|
255
|
+
save();
|
|
256
|
+
return json(200, listed(p));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
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
|
+
}
|
|
162
302
|
if (req.method === 'PUT' && rest === '/assets') {
|
|
163
303
|
const { files } = readJson(req);
|
|
164
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
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
// Output is always JSON; exit code 1 on an error or a failed check.
|
|
5
5
|
import * as cmd from './commands.js';
|
|
6
6
|
import { CliError, print } from './output.js';
|
|
7
|
-
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code']);
|
|
7
|
+
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file']);
|
|
8
8
|
const argv = process.argv.slice(2);
|
|
9
9
|
const flags = new Set();
|
|
10
10
|
const values = {};
|
|
11
11
|
const args = [];
|
|
12
12
|
for (let i = 0; i < argv.length; i++) {
|
|
13
13
|
const a = argv[i];
|
|
14
|
+
if (a === '-f' && i + 1 < argv.length) {
|
|
15
|
+
values.file = argv[++i];
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
14
18
|
if (!a.startsWith('--')) {
|
|
15
19
|
args.push(a);
|
|
16
20
|
continue;
|
|
@@ -53,6 +57,32 @@ async function run() {
|
|
|
53
57
|
if (sub === 'check')
|
|
54
58
|
return cmd.storefrontCheckCmd();
|
|
55
59
|
throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
|
|
60
|
+
case 'promotions':
|
|
61
|
+
if (sub === 'list')
|
|
62
|
+
return cmd.promotionsList();
|
|
63
|
+
if (sub === 'show')
|
|
64
|
+
return cmd.promotionsShow(args[2]);
|
|
65
|
+
if (sub === 'create')
|
|
66
|
+
return cmd.promotionsCreate(values);
|
|
67
|
+
if (sub === 'update')
|
|
68
|
+
return cmd.promotionsUpdate(args[2], values);
|
|
69
|
+
if (sub === 'enable' || sub === 'disable')
|
|
70
|
+
return cmd.promotionsEnable(args[2], sub === 'enable');
|
|
71
|
+
if (sub === 'delete')
|
|
72
|
+
return cmd.promotionsDelete(args[2]);
|
|
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>`.');
|
|
56
86
|
case 'orders':
|
|
57
87
|
if (sub === 'test')
|
|
58
88
|
return cmd.ordersTest(flags, values);
|