@commercelayer/agent-cli 0.3.0 → 0.5.0
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 +37 -11
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +43 -3
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +85 -18
- package/dist/api/stub/theme.js +121 -31
- package/dist/api/stub/ucp.js +20 -7
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +162 -32
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +126 -11
- package/dist/ucp/shop.js +66 -19
- package/package.json +1 -1
package/dist/api/stub/theme.js
CHANGED
|
@@ -4,34 +4,134 @@
|
|
|
4
4
|
// at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
|
|
5
5
|
// A product without an image shows its tile, and an unset hero the generated one, both
|
|
6
6
|
// drawn at /tiles/ (renderTile) as the store host does.
|
|
7
|
-
|
|
7
|
+
import { baseLanguage, cookieLocale, COOKIE, localeOfCountry, localesOf, texts } from './locale.js';
|
|
8
|
+
export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
8
9
|
/** Black or white, whichever reads better on the accent. */
|
|
9
10
|
function onAccent(hex) {
|
|
10
11
|
const h = hex.length === 4 ? hex.slice(1).replace(/./g, (c) => c + c) : hex.slice(1);
|
|
11
12
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
12
13
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? '#000000' : '#ffffff';
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
/** The selector's strings, in the store's base language; other languages fall back to en, like the store. */
|
|
16
|
+
const UI = {
|
|
17
|
+
en: { choose: 'Choose your country', shipping: (c) => `Shipping to ${c}?` },
|
|
18
|
+
it: { choose: 'Scegli il tuo paese', shipping: (c) => `Spedisci in ${c}?` },
|
|
19
|
+
fr: { choose: 'Choisissez votre pays', shipping: (c) => `Livraison en ${c} ?` },
|
|
20
|
+
de: { choose: 'Wählen Sie Ihr Land', shipping: (c) => `Versand nach ${c}?` },
|
|
21
|
+
es: { choose: 'Elige tu país', shipping: (c) => `¿Envío a ${c}?` },
|
|
22
|
+
};
|
|
23
|
+
const countryName = (cc, ll) => {
|
|
24
|
+
try {
|
|
25
|
+
return new Intl.DisplayNames([ll], { type: 'region' }).of(cc) ?? cc;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return cc;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const tag = (l) => `${l.language}-${l.country}`;
|
|
32
|
+
const alternates = (locales, rest) => [...locales.map((l) => `<link rel="alternate" hreflang="${tag(l)}" href="/${l.path}${rest === '/' ? '/' : rest}">`), '<link rel="alternate" hreflang="x-default" href="/">'].join('\n');
|
|
33
|
+
/**
|
|
34
|
+
* The hosted theme's routes. With locales (two or more countries, or one): the selector at /,
|
|
35
|
+
* the pages under /<cc>-<ll>/, a cookie that remembers the choice, unprefixed product and
|
|
36
|
+
* category paths sent to the remembered or first locale. Without (not applied since locales
|
|
37
|
+
* exist): the site as it was. null when the path is not the theme's.
|
|
38
|
+
*/
|
|
39
|
+
export function renderTheme(r, path, search, headers = {}) {
|
|
40
|
+
const locales = localesOf(r);
|
|
41
|
+
if (!locales.length)
|
|
42
|
+
return renderPage(r, path, search, null);
|
|
43
|
+
const redirect = (to, extra = {}) => ({ status: 302, headers: { location: to, 'cache-control': 'no-store', ...extra }, body: '' });
|
|
44
|
+
if (path === '/') {
|
|
45
|
+
if (locales.length === 1)
|
|
46
|
+
return redirect(`/${locales[0].path}/`);
|
|
47
|
+
const chosen = cookieLocale(r, headers.cookie);
|
|
48
|
+
if (chosen && !search.has('change'))
|
|
49
|
+
return redirect(`/${chosen.path}/`, { vary: 'Cookie' });
|
|
50
|
+
const page = selector(r, locales, headers['cf-ipcountry']);
|
|
51
|
+
page.headers.vary = 'Cookie';
|
|
52
|
+
return page;
|
|
53
|
+
}
|
|
54
|
+
const m = path.match(/^\/([a-z]{2})-([a-z]{2})(\/.*)?$/);
|
|
55
|
+
if (m) {
|
|
56
|
+
const l = localeOfCountry(r, m[1]);
|
|
57
|
+
if (!l)
|
|
58
|
+
return null;
|
|
59
|
+
const rest = m[3] ?? '/';
|
|
60
|
+
if (l.language !== m[2])
|
|
61
|
+
return redirect(`/${l.path}${rest === '/' ? '/' : rest}`);
|
|
62
|
+
const page = renderPage(r, rest, search, l);
|
|
63
|
+
if (page)
|
|
64
|
+
page.headers['set-cookie'] = `${COOKIE}=${l.path}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
65
|
+
return page;
|
|
66
|
+
}
|
|
67
|
+
if (/^\/(products|categories)\//.test(path))
|
|
68
|
+
return redirect(`/${(cookieLocale(r, headers.cookie) ?? locales[0]).path}${path}`, { vary: 'Cookie' });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function selector(r, locales, ipCountry) {
|
|
72
|
+
const base = baseLanguage(r);
|
|
73
|
+
const ui = UI[base] ?? UI.en;
|
|
16
74
|
const markets = Object.values(r.applied?.markets ?? {});
|
|
75
|
+
const near = ipCountry ? localeOfCountry(r, ipCountry) : undefined;
|
|
76
|
+
const link = (l) => `<a href="/${l.path}/" hreflang="${tag(l)}" lang="${l.language}">${esc(countryName(l.country, base))}</a>`;
|
|
77
|
+
const body = `<main class="selector"><h1>${esc(ui.choose)}</h1>${near ? `<p class="suggestion"><a href="/${near.path}/">${esc(ui.shipping(countryName(near.country, base)))}</a></p>` : ''}${markets
|
|
78
|
+
.map((mk) => {
|
|
79
|
+
const ls = locales.filter((l) => l.market === mk.code);
|
|
80
|
+
return ls.length ? `<section><h2>${esc(mk.name)} (${esc(mk.currency)})</h2><ul>${ls.map((l) => `<li>${link(l)}</li>`).join('')}</ul></section>` : '';
|
|
81
|
+
})
|
|
82
|
+
.join('')}</main>`;
|
|
83
|
+
return page(r, { lang: base, title: r.name ?? r.org, head: alternates(locales, '/'), header: '', body, description: ui.choose });
|
|
84
|
+
}
|
|
85
|
+
function page(r, o) {
|
|
17
86
|
const theme = r.theme ?? {};
|
|
18
87
|
const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(theme.color ?? '') ? theme.color : '#2f5d50';
|
|
19
88
|
const name = r.name ?? r.org;
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
89
|
+
const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
|
|
90
|
+
const logo = (theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`) + badge;
|
|
91
|
+
const html = `<!doctype html>
|
|
92
|
+
<html lang="${esc(o.lang)}">
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8">
|
|
95
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
|
+
<title>${esc(o.title)}</title>
|
|
97
|
+
<meta name="description" content="${esc(o.description)}">
|
|
98
|
+
${o.head ? `${o.head}\n` : ''}<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
99
|
+
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<header><a href="${o.home ?? '/'}">${logo}</a>${o.header}</header>
|
|
103
|
+
${o.body}
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
`;
|
|
107
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
108
|
+
}
|
|
109
|
+
/** The home page and the product pages, for a locale (prefixed paths, its market, its language) or for none (the site as it was). */
|
|
110
|
+
function renderPage(r, path, search, locale) {
|
|
111
|
+
const products = Object.values(r.applied?.products ?? {});
|
|
112
|
+
const markets = Object.values(r.applied?.markets ?? {});
|
|
113
|
+
const theme = r.theme ?? {};
|
|
114
|
+
const name = r.name ?? r.org;
|
|
115
|
+
const base = baseLanguage(r);
|
|
116
|
+
const ll = locale?.language ?? base;
|
|
117
|
+
const market = locale ? r.applied?.markets[locale.market] : (markets.find((m) => m.code === search.get('market')) ?? markets[0]);
|
|
118
|
+
const prefix = locale ? `/${locale.path}` : '';
|
|
119
|
+
const keep = !locale && market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
|
|
120
|
+
// Under a locale, prices as that country writes them (14,50 € on /it-it/); the site as it was keeps EUR 14.50.
|
|
121
|
+
const money = (amount, currency) => (locale ? new Intl.NumberFormat(tag(locale), { style: 'currency', currency }).format(amount) : `${currency} ${amount.toFixed(2)}`);
|
|
122
|
+
const price = (p) => (market && p.prices[market.currency] !== undefined ? money(p.prices[market.currency], market.currency) : '');
|
|
23
123
|
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
24
|
-
const logo = theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`;
|
|
25
124
|
const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
125
|
+
const lang = ll === base ? '' : `?lang=${ll}`;
|
|
126
|
+
const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(texts(p, ll).name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg${lang}" alt="${esc(texts(p, ll).name)}" loading="lazy"></span>`;
|
|
127
|
+
const card = (p) => `<article class="product"><a href="${prefix}/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(texts(p, ll).name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
|
|
28
128
|
let title = name;
|
|
29
129
|
let body;
|
|
30
130
|
const m = path.match(/^\/products\/([^/]+)$/);
|
|
31
131
|
if (path === '/') {
|
|
32
132
|
const groups = new Map();
|
|
33
133
|
for (const p of products) {
|
|
34
|
-
const top = p.category?.split(' > ')[0] ?? 'Other';
|
|
134
|
+
const top = texts(p, ll).category?.split(' > ')[0] ?? 'Other';
|
|
35
135
|
groups.set(top, [...(groups.get(top) ?? []), p]);
|
|
36
136
|
}
|
|
37
137
|
const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
|
|
@@ -41,32 +141,22 @@ export function renderTheme(r, path, search) {
|
|
|
41
141
|
const p = r.applied?.products[decodeURIComponent(m[1])];
|
|
42
142
|
if (!p)
|
|
43
143
|
return null;
|
|
44
|
-
|
|
45
|
-
|
|
144
|
+
const t = texts(p, ll);
|
|
145
|
+
title = `${t.name} · ${name}`;
|
|
146
|
+
body = `<main class="product-page">${image(p)}<div>${t.category ? `<p class="category">${esc(t.category)}</p>` : ''}<h1>${esc(t.name)}</h1>${t.description ? `<p>${esc(t.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
|
|
46
147
|
}
|
|
47
148
|
else
|
|
48
149
|
return null;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<meta name="description" content="${esc(tagline)}">
|
|
56
|
-
<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
57
|
-
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
58
|
-
</head>
|
|
59
|
-
<body>
|
|
60
|
-
<header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
|
|
61
|
-
${body}
|
|
62
|
-
</body>
|
|
63
|
-
</html>
|
|
64
|
-
`;
|
|
65
|
-
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
150
|
+
const header = locale
|
|
151
|
+
? localesOf(r).length > 1
|
|
152
|
+
? `<nav><a class="country" href="/?change">${esc(countryName(locale.country, ll))} · ${esc(market?.currency ?? '')}</a></nav>`
|
|
153
|
+
: ''
|
|
154
|
+
: `<nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav>`;
|
|
155
|
+
return page(r, { lang: locale ? tag(locale) : 'en', title, head: locale ? alternates(localesOf(r), path) : '', header, body, description: tagline, home: `${prefix}/${keep}` });
|
|
66
156
|
}
|
|
67
157
|
const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
68
158
|
/** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
|
|
69
|
-
export function renderTile(r, path) {
|
|
159
|
+
export function renderTile(r, path, search) {
|
|
70
160
|
const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
|
|
71
161
|
if (!m)
|
|
72
162
|
return null;
|
|
@@ -86,7 +176,7 @@ export function renderTile(r, path) {
|
|
|
86
176
|
const p = r.applied?.products[sku];
|
|
87
177
|
if (!p)
|
|
88
178
|
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>`;
|
|
179
|
+
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(texts(p, search?.get('lang') ?? '').name.slice(0, 60))}</text></svg>`;
|
|
90
180
|
}
|
|
91
181
|
return { status: 200, headers, body: svg };
|
|
92
182
|
}
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -8,7 +8,8 @@ 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, renderTile } from './theme.js';
|
|
11
|
+
import { esc, renderTheme, renderTile } from './theme.js';
|
|
12
|
+
import { ucpLanguage } from './locale.js';
|
|
12
13
|
const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
|
|
13
14
|
const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
|
|
14
15
|
// ---------- profile
|
|
@@ -107,8 +108,11 @@ function handleService(req, r, origin, path) {
|
|
|
107
108
|
return { status: seen.status, headers: { ...seen.headers, 'idempotent-replayed': 'true' }, body: seen.body };
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
// 3. The operation.
|
|
111
|
+
// 3. The operation, in the language the platform asked for (or the buyer's country's, or the base).
|
|
112
|
+
ctx.language = ucpLanguage(r, parsed?.context, req.headers['accept-language']);
|
|
113
|
+
ctx.country = typeof parsed?.context?.address_country === 'string' ? parsed.context.address_country : undefined;
|
|
111
114
|
const res = dispatch(ctx, req.method, path, parsed);
|
|
115
|
+
res.headers['content-language'] = ctx.language;
|
|
112
116
|
if (mutating && key)
|
|
113
117
|
r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
|
|
114
118
|
return res;
|
|
@@ -303,14 +307,21 @@ function handlePermalink(req, r, origin, path) {
|
|
|
303
307
|
return { status: 303, headers: { location: target, ...safe }, body: '' };
|
|
304
308
|
}
|
|
305
309
|
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
|
-
function serveStorefront(r, path, search) {
|
|
310
|
+
function serveStorefront(r, path, search, headers) {
|
|
307
311
|
const files = r.ucp.files;
|
|
308
312
|
// /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
|
|
309
313
|
if (path.startsWith('/tiles/'))
|
|
310
|
-
return renderTile(r, path) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
314
|
+
return renderTile(r, path, search) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
315
|
+
// Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
|
|
316
|
+
const agentOnly = r.storefront_mode === 'agent-only';
|
|
317
|
+
// Links shared while the store was hosted land on the agent page.
|
|
318
|
+
if (agentOnly && /^(\/[a-z]{2}-[a-z]{2})?\/(products|categories)\/./.test(path))
|
|
319
|
+
return { status: 302, headers: { location: '/' }, body: '' };
|
|
320
|
+
if (agentOnly && path === '/')
|
|
321
|
+
return html(200, r.name ?? r.org, `<p>${esc(r.name ?? r.org)} sells to shopping agents over UCP: <a href="/.well-known/ucp">/.well-known/ucp</a>.</p>`);
|
|
311
322
|
// A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
|
|
312
|
-
if (files['index.html'] === undefined) {
|
|
313
|
-
const page = renderTheme(r, path, search);
|
|
323
|
+
if (!agentOnly && files['index.html'] === undefined) {
|
|
324
|
+
const page = renderTheme(r, path, search, headers);
|
|
314
325
|
if (page)
|
|
315
326
|
return page;
|
|
316
327
|
}
|
|
@@ -320,6 +331,8 @@ function serveStorefront(r, path, search) {
|
|
|
320
331
|
const ext = path.split('.').pop() ?? '';
|
|
321
332
|
return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(asset, 'base64').toString('binary') };
|
|
322
333
|
}
|
|
334
|
+
if (agentOnly)
|
|
335
|
+
return html(404, 'Not found', `<p>This store has no pages for ${esc(path)}: it sells over UCP.</p>`);
|
|
323
336
|
const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
|
|
324
337
|
for (const c of candidates) {
|
|
325
338
|
if (files[c] !== undefined) {
|
|
@@ -356,7 +369,7 @@ export function handleUcp(req, r) {
|
|
|
356
369
|
return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
|
|
357
370
|
return html(200, `${kind === 'order' ? `Order ${found.label}` : `Your ${kind}`}`, `<p>${kind === 'order' ? 'Thank you. ' : ''}${found.line_items.length} line item(s), ${found.currency} ${(found.totals.at(-1).amount / 100).toFixed(2)}.</p>`);
|
|
358
371
|
}
|
|
359
|
-
return serveStorefront(r, path, req.url.searchParams);
|
|
372
|
+
return serveStorefront(r, path, req.url.searchParams, req.headers);
|
|
360
373
|
}
|
|
361
374
|
/** Lets the service read the bearer token for order authorization. */
|
|
362
375
|
export function bearerOf(req) {
|
package/dist/bin.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
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', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping']);
|
|
7
|
+
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url']);
|
|
8
8
|
const argv = process.argv.slice(2);
|
|
9
9
|
const flags = new Set();
|
|
10
10
|
const values = {};
|
|
11
|
-
/** Every value of an option given more than once (--item, --code), in order; values keeps the last. */
|
|
11
|
+
/** Every value of an option given more than once (--item, --code, --category), in order; values keeps the last. */
|
|
12
12
|
const lists = {};
|
|
13
13
|
const set = (k, v) => {
|
|
14
14
|
values[k] = v;
|
|
@@ -93,17 +93,27 @@ async function run() {
|
|
|
93
93
|
if (sub === 'test')
|
|
94
94
|
return cmd.ordersTest(flags, values);
|
|
95
95
|
if (sub === 'list')
|
|
96
|
-
return cmd.ordersList();
|
|
96
|
+
return cmd.ordersList(values, flags);
|
|
97
97
|
if (sub === 'show')
|
|
98
98
|
return cmd.ordersShow(args[2]);
|
|
99
|
-
|
|
99
|
+
if (sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
|
|
100
|
+
return cmd.ordersAct(sub, args[2], values, flags);
|
|
101
|
+
throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders list`, `show <number>`, `approve <number> [--capture]`, `capture`, `cancel`, `refund <number> [--amount <n>]`, `archive`, `unarchive`, or `orders test`.');
|
|
102
|
+
case 'fulfillment':
|
|
103
|
+
if (sub === 'list')
|
|
104
|
+
return cmd.fulfillmentList(values);
|
|
105
|
+
if (sub === 'show')
|
|
106
|
+
return cmd.fulfillmentShow(args[2]);
|
|
107
|
+
if (sub === 'hold' || sub === 'pick' || sub === 'pack' || sub === 'ready' || sub === 'ship' || sub === 'deliver')
|
|
108
|
+
return cmd.fulfillmentAct(sub, args[2], values);
|
|
109
|
+
throw new CliError('unknown_command', `There is no command "fulfillment ${sub ?? ''}".`, 'Use `c11r fulfillment list`, `show <shipment>`, `hold`, `pick`, `pack`, `ready`, `ship <shipment> [--carrier --tracking --tracking-url]` or `deliver`.');
|
|
100
110
|
case 'ucp':
|
|
101
111
|
if (sub === 'profile')
|
|
102
112
|
return cmd.ucpProfile(flags);
|
|
103
113
|
if (sub === 'check')
|
|
104
114
|
return cmd.ucpCheck();
|
|
105
115
|
if (sub === 'search')
|
|
106
|
-
return cmd.ucpSearch(args.slice(2).join(' '), values);
|
|
116
|
+
return cmd.ucpSearch(args.slice(2).join(' '), values, lists);
|
|
107
117
|
if (sub === 'product')
|
|
108
118
|
return cmd.ucpProduct(args[2], values);
|
|
109
119
|
if (sub === 'order')
|
package/dist/catalog.js
CHANGED
|
@@ -9,7 +9,8 @@ import YAML from 'yaml';
|
|
|
9
9
|
import { credential, paths } from './files.js';
|
|
10
10
|
import { CliError, fail } from './output.js';
|
|
11
11
|
import { parseCsv, readProductFile, toCsv } from './products.js';
|
|
12
|
-
import {
|
|
12
|
+
import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
|
|
13
|
+
import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
|
|
13
14
|
export const SOURCES = ['supabase', 'sheets', 'merchant'];
|
|
14
15
|
export const catalogFile = path.join(paths.products, 'catalog.csv');
|
|
15
16
|
const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
|
|
@@ -33,7 +34,21 @@ function readConfig(doc) {
|
|
|
33
34
|
? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
|
|
34
35
|
: 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
const translations = {};
|
|
38
|
+
if (c.translations !== undefined && c.translations !== null) {
|
|
39
|
+
if (c.source !== 'merchant')
|
|
40
|
+
fail('invalid_catalog', `catalog.translations is for merchant feeds; this source is ${c.source}.`, 'In a sheet or a table, translations are columns: name_it, description_it, category_it.');
|
|
41
|
+
if (typeof c.translations !== 'object' || Array.isArray(c.translations))
|
|
42
|
+
fail('invalid_catalog', 'catalog.translations in store.yml is not a map.', 'Map each language to its feed: translations: { it: https://..., fr: https://... }.');
|
|
43
|
+
for (const [ll, u] of Object.entries(c.translations)) {
|
|
44
|
+
if (!ISO_639_1.has(ll))
|
|
45
|
+
fail('invalid_catalog', `catalog.translations.${ll} is not a language.`, 'Use ISO 639-1 codes in lower case: it, fr, de, ...');
|
|
46
|
+
if (!/^https?:\/\//.test(String(u)))
|
|
47
|
+
fail('invalid_catalog', `catalog.translations.${ll} is not a URL.`, 'Set it to the Merchant Center feed in that language (RSS/XML or TSV).');
|
|
48
|
+
translations[ll] = String(u);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { source: c.source, url, table, translations };
|
|
37
52
|
}
|
|
38
53
|
async function get(url, what, headers = {}) {
|
|
39
54
|
try {
|
|
@@ -51,13 +66,16 @@ function fromTable(header, rows, where, label, currencies) {
|
|
|
51
66
|
const key = h.trim().toLowerCase();
|
|
52
67
|
if (FIELDS.includes(key))
|
|
53
68
|
return { field: key };
|
|
69
|
+
const t = translatedColumn(key);
|
|
70
|
+
if (t)
|
|
71
|
+
return { translated: t.field, language: t.language };
|
|
54
72
|
if (key === '' || IGNORED.includes(key))
|
|
55
73
|
return null;
|
|
56
74
|
const code = /^[a-z]{3}$/.test(key);
|
|
57
75
|
if (code && (currencies.length === 0 || currencies.includes(key.toUpperCase())))
|
|
58
76
|
return { currency: key.toUpperCase() };
|
|
59
77
|
const why = key === 'stock' ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : code ? `${key.toUpperCase()} is not the currency of a market in store.yml. ` : '';
|
|
60
|
-
fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')} and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
|
|
78
|
+
fail('invalid_product', `${where} has a column "${h}" that is not a product field.`, `${why}The catalog columns are ${FIELDS.join(', ')}, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, named by its ISO 4217 code in lowercase (${prices}). Ask the human to rename or remove the column.`);
|
|
61
79
|
});
|
|
62
80
|
if (!columns.some((c) => c && 'field' in c && c.field === 'sku'))
|
|
63
81
|
fail('invalid_product', `${where} has no sku column.`, `The catalog columns are ${FIELDS.join(', ')} and one price column per market currency (${prices}).`);
|
|
@@ -76,6 +94,10 @@ function fromTable(header, rows, where, label, currencies) {
|
|
|
76
94
|
fail('invalid_product', `${label(i)}: ${col.currency} price "${v}" is not a number.`, 'Write prices as plain decimals, such as 14.50, with no currency sign or thousands separator.');
|
|
77
95
|
product.prices[col.currency] = price;
|
|
78
96
|
}
|
|
97
|
+
else if ('translated' in col)
|
|
98
|
+
setTranslated(product, col.translated, col.language, String(v).trim());
|
|
99
|
+
else if (TRANSLATED.includes(col.field))
|
|
100
|
+
setPlain(product, col.field, String(v).trim());
|
|
79
101
|
else
|
|
80
102
|
product[col.field] = String(v).trim();
|
|
81
103
|
});
|
|
@@ -233,6 +255,20 @@ async function fromMerchant(c) {
|
|
|
233
255
|
bySku.set(item.id, sourced);
|
|
234
256
|
out.push(sourced);
|
|
235
257
|
});
|
|
258
|
+
// A feed per language: title, description and product_type of the same ids, in that language.
|
|
259
|
+
for (const [ll, url] of Object.entries(c.translations)) {
|
|
260
|
+
const res = await get(url, `the ${ll} Merchant feed`);
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
fail('catalog_error', `The ${ll} Merchant feed answered ${res.status}.`, `Check catalog.translations.${ll} in store.yml: it is the feed URL Merchant Center fetches for that language.`);
|
|
263
|
+
for (const item of feedRows(await res.text()).items) {
|
|
264
|
+
const target = item.id ? bySku.get(item.id)?.product : undefined;
|
|
265
|
+
if (!target)
|
|
266
|
+
continue;
|
|
267
|
+
for (const [tag, field] of [['title', 'name'], ['description', 'description'], ['product_type', 'category']])
|
|
268
|
+
if (item[tag])
|
|
269
|
+
setTranslated(target, field, ll, item[tag]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
236
272
|
return out;
|
|
237
273
|
}
|
|
238
274
|
export async function pull() {
|
|
@@ -254,7 +290,7 @@ export async function pull() {
|
|
|
254
290
|
const rls = config.source === 'supabase' ? ' If the table has rows, row level security hides them from the anon key: ask the human to add a policy that allows select.' : '';
|
|
255
291
|
fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
|
|
256
292
|
}
|
|
257
|
-
const products = checkProducts(sourced, currencies);
|
|
293
|
+
const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
|
|
258
294
|
let before = new Map();
|
|
259
295
|
if (fs.existsSync(catalogFile)) {
|
|
260
296
|
try {
|