@commercelayer/agent-cli 0.3.1 → 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.
@@ -0,0 +1,78 @@
1
+ // A copy of the store Worker's search (commercelayer-ai-store src/store/search.ts), so the
2
+ // stub ranks as the store does. Keep it in step with the store's; only the import differs.
3
+ //
4
+ // Catalog search over what apply sent: words in any order, ranked. A query is folded
5
+ // (case and accents aside), split into words, stripped of the stop words of the theme's
6
+ // languages and of plurals; each product scores by where its words appear (title over
7
+ // category over description, in its base text or any translation) and by how many of
8
+ // the query's words it has. Products with every word come first, then those with some;
9
+ // ties go by sku. No embeddings: what a word means is not known here, only that it is there.
10
+ const WEIGHT = { sku: 8, title: 4, category: 2, description: 1 };
11
+ /** Lower case, without accents: "Caffè" and "caffe" compare equal. */
12
+ export const fold = (t) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
13
+ // The theme's languages: en, it, fr, de, es. Words that say nothing about a product.
14
+ const STOP = new Set(`a an and any are as at be best but by can do does for from get good have i in into is it its looking me my need of on or our please some something show that the their them these this those to want with without you your
15
+ il lo la i gli le un uno una di da del della dei delle per con su e o che qualcosa cerco vorrei voglio mi un po
16
+ le la les un une des de du d et ou pour avec sur dans au aux en quelque chose je veux cherche
17
+ der die das ein eine einen einem und oder fur mit auf im in zu etwas ich suche mochte
18
+ el la los las un una unos unas de del y o para con en algo busco quiero`.split(/\s+/));
19
+ /** A word in one spelling: folded, and singular for the common plurals (beans, berries, boxes). */
20
+ function stem(word) {
21
+ if (word.length > 4 && word.endsWith('ies'))
22
+ return `${word.slice(0, -3)}y`;
23
+ if (word.length > 4 && /(ches|shes|sses|xes)$/.test(word))
24
+ return word.slice(0, -2);
25
+ if (word.length > 3 && word.endsWith('s') && !/(ss|us|is)$/.test(word))
26
+ return word.slice(0, -1);
27
+ return word;
28
+ }
29
+ const wordsIn = (text) => (text ? fold(text).split(/[^\p{L}\p{N}]+/u).filter(Boolean).map(stem) : []);
30
+ /** The words a query searches for: no stop words, no repeats, in the order given. */
31
+ export function queryWords(text) {
32
+ return [...new Set(wordsIn(text).filter((w) => !STOP.has(w)))];
33
+ }
34
+ /** A product's words by field, its base text and every translation together. */
35
+ function fieldsOf(p) {
36
+ const texts = [p, ...Object.values(p.translations ?? {})];
37
+ return {
38
+ sku: new Set([fold(p.sku), ...wordsIn(p.sku)]),
39
+ title: new Set(texts.flatMap((t) => wordsIn(t.name))),
40
+ category: new Set(texts.flatMap((t) => wordsIn(t.category))),
41
+ description: new Set(texts.flatMap((t) => wordsIn(t.description))),
42
+ };
43
+ }
44
+ /** A word is in a field when the field has it, or (for words of four letters or more) a word that starts with it: "filt" finds "filter". */
45
+ const has = (field, word) => field.has(word) || (word.length >= 4 && [...field].some((w) => w.startsWith(word)));
46
+ /** How well a product matches the query's words, or null when none of them is in it. */
47
+ export function scoreOf(p, words) {
48
+ if (!words.length)
49
+ return { score: 0, words: [], matched: [], all: true };
50
+ const fields = fieldsOf(p);
51
+ const found = [];
52
+ const matched = new Set();
53
+ let score = 0;
54
+ for (const w of words) {
55
+ const where = Object.keys(WEIGHT).filter((f) => has(fields[f], w));
56
+ if (!where.length)
57
+ continue;
58
+ found.push(w);
59
+ where.forEach((f) => matched.add(f));
60
+ score += WEIGHT[where[0]];
61
+ }
62
+ if (!found.length)
63
+ return null;
64
+ const all = found.length === words.length;
65
+ // Every word beats some words; more words beat better places.
66
+ score += found.length * 10 + (all ? 100 : 0);
67
+ return { score, words: found, matched: Object.keys(WEIGHT).filter((f) => matched.has(f)), all };
68
+ }
69
+ /** The products matching a query, best first; ties by sku. An empty query (or only stop words) matches everything, by sku. */
70
+ export function rank(products, text) {
71
+ const words = queryWords(text);
72
+ return products
73
+ .flatMap((product) => {
74
+ const match = scoreOf(product, words);
75
+ return match ? [{ product, match }] : [];
76
+ })
77
+ .sort((a, b) => b.match.score - a.match.score || (a.product.sku < b.product.sku ? -1 : a.product.sku > b.product.sku ? 1 : 0));
78
+ }
@@ -3,6 +3,9 @@
3
3
  // schemas; src/ucp/platform.ts holds the constants shared with the client.
4
4
  import crypto from 'node:crypto';
5
5
  import { SANDBOX_CREDENTIAL_TYPE, SANDBOX_HANDLER, SANDBOX_HANDLER_ID, SANDBOX_TOKEN_DECLINED, SANDBOX_TOKEN_SUCCESS, toMinor, UCP_VERSION } from '../../ucp/platform.js';
6
+ import { baseLanguage, marketLocale, texts } from './locale.js';
7
+ import { queryWords, rank } from './search.js';
8
+ const lang = (ctx) => ctx.language ?? baseLanguage(ctx.record);
6
9
  /** The discount code every sandbox accepts: 10% off the items. */
7
10
  const SANDBOX_CODE = 'SANDBOX10';
8
11
  const CONSENT_PURPOSES = {
@@ -88,62 +91,123 @@ function returnPolicy(ctx) {
88
91
  // ---------- catalog
89
92
  /** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL,
90
93
  * 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;
92
- function variant(ctx, p, market) {
94
+ const imageUrl = (ctx, p) => new URL(p.image_url ?? `tiles/${encodeURIComponent(p.sku)}.svg${lang(ctx) === baseLanguage(ctx.record) ? '' : `?lang=${lang(ctx)}`}`, `${ctx.origin}/`).href;
95
+ /** A product page: under the locale of the buyer's country when it is in the market, else the market's first. */
96
+ function productUrl(ctx, p, market) {
97
+ if (ctx.record.storefront_mode === 'agent-only')
98
+ return undefined;
99
+ const l = marketLocale(ctx.record, market.code, ctx.country);
100
+ return `${ctx.origin}${l ? `/${l.path}` : ''}/products/${encodeURIComponent(p.sku)}`;
101
+ }
102
+ /** asked: the country the buyer asked about (filters.fulfills_to, else context), whose shipping the variant answers for. */
103
+ function variant(ctx, p, market, asked) {
93
104
  const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
94
105
  const v = {
95
106
  id: p.sku,
96
107
  sku: p.sku,
97
- title: p.name,
98
- description: { plain: p.description ?? p.name },
108
+ title: texts(p, lang(ctx)).name,
109
+ description: { plain: texts(p, lang(ctx)).description ?? texts(p, lang(ctx)).name },
99
110
  // Agent-only stores have no product pages to link to.
100
- url: ctx.record.storefront_mode === 'agent-only' ? undefined : `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
111
+ url: productUrl(ctx, p, market),
101
112
  price,
102
113
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
103
114
  availability: { available: true, status: 'in_stock' },
115
+ // Like the store: which market the price is for. tax_included comes from a Commerce Layer price list: none here.
116
+ metadata: { prices: { market: market.code, market_name: market.name, currency: market.currency, countries: market.countries } },
104
117
  };
105
118
  v.media = [{ type: 'image', url: imageUrl(ctx, p) }];
106
119
  if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
107
120
  const o = shippingOption(market);
108
- 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 }] }] };
121
+ // days is "3" or "3-5": the earliest and latest arrival, at noon UTC.
122
+ const [first, last = first] = String(market.shipping?.days ?? '0').split('-').map((d) => Number(d.trim()) || 0);
123
+ const day = (n) => {
124
+ const at = new Date(Date.now() + n * 86_400_000);
125
+ at.setUTCHours(12, 0, 0, 0);
126
+ return at.toISOString().replace('.000', '');
127
+ };
128
+ const ships = !asked || market.countries.includes(asked.toUpperCase());
129
+ const availability = ships ? v.availability : { available: false, status: 'out_of_stock' };
130
+ const method = {
131
+ type: 'shipping',
132
+ description: { plain: `Ships to ${market.countries.join(', ')}` },
133
+ ships_to: market.countries,
134
+ availability,
135
+ options: ships ? [{ id: o.id, title: o.title, description: o.description, totals: [{ type: 'fulfillment', display_text: 'Shipping', amount: o.totals[0].amount }], earliest_fulfillment_time: day(first), latest_fulfillment_time: day(last) }] : [],
136
+ };
137
+ if (asked)
138
+ method.destination = { address_country: asked.toUpperCase(), ships };
139
+ // Like the store: the variant stays available; the method says it does not ship there.
140
+ v.fulfillment = { methods: [method] };
109
141
  }
110
142
  return v;
111
143
  }
112
- function product(ctx, p, market) {
113
- const v = variant(ctx, p, market);
144
+ function product(ctx, p, market, asked, match) {
145
+ const v = variant(ctx, p, market, asked);
114
146
  const out = {
115
147
  id: `prod_${p.sku}`,
116
148
  handle: p.sku.toLowerCase(),
117
- title: p.name,
118
- description: { plain: p.description ?? p.name },
149
+ title: v.title,
150
+ description: v.description,
119
151
  url: v.url,
120
152
  price_range: { min: v.price, max: v.price },
121
153
  variants: [v],
122
154
  };
123
155
  if (v.media)
124
156
  out.media = v.media;
157
+ // Like the store: the base language's path, which filters.categories matches.
158
+ if (p.category)
159
+ out.categories = [{ value: p.category, taxonomy: 'merchant' }];
160
+ if (match && match.words.length)
161
+ out.metadata = { search: { score: match.score, all_words: match.all, words: match.words, matched_on: match.matched } };
125
162
  return out;
126
163
  }
164
+ /** A category path folded as the store folds it: case and spacing, not accents. */
165
+ const categoryKey = (c) => c.split('>').map((l) => l.trim().toLowerCase().replace(/\s+/g, ' ')).join(' > ');
127
166
  export function searchCatalog(ctx, body) {
128
167
  const market = pickMarket(ctx, body.context);
129
168
  if (!market)
130
169
  return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 } };
131
- const q = String(body.query ?? '').toLowerCase().trim();
170
+ const q = String(body.query ?? '').trim();
132
171
  const max = body.filters?.price?.max;
133
172
  const min = body.filters?.price?.min;
173
+ const asked = body.filters?.fulfills_to?.address_country ?? body.context?.address_country;
174
+ // Like the store: filters.fulfills_to restricts the results to what ships there (context alone only seeds availability).
175
+ const shipsTo = body.filters?.fulfills_to?.address_country?.toUpperCase();
176
+ if (shipsTo && !market.countries.includes(shipsTo)) {
177
+ const other = markets(ctx).find((m) => m.countries.includes(shipsTo));
178
+ const content = other
179
+ ? `The ${market.name} market ships to ${market.countries.join(', ')}, not to ${shipsTo}. The ${other.name} market (${other.currency}) does: send context.address_country ${shipsTo} for its prices.`
180
+ : `The store does not ship to ${shipsTo}.`;
181
+ return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 }, messages: [{ type: 'info', code: 'no_results', path: '$.filters.fulfills_to.address_country', content }] };
182
+ }
134
183
  let all = Object.values(applied(ctx).products).filter((p) => p.prices[market.currency] !== undefined);
135
- if (q)
136
- all = all.filter((p) => `${p.name} ${p.description ?? ''} ${p.sku}`.toLowerCase().includes(q));
184
+ // Like the store: a category matches itself and the paths below it, by whole levels.
185
+ const wanted = (body.filters?.categories ?? []).map((c) => categoryKey(String(c)));
186
+ if (wanted.length)
187
+ all = all.filter((p) => p.category && wanted.some((w) => categoryKey(p.category) === w || categoryKey(p.category).startsWith(`${w} > `)));
137
188
  all = all.filter((p) => (min === undefined || toMinor(p.prices[market.currency], market.currency) >= min) && (max === undefined || toMinor(p.prices[market.currency], market.currency) <= max));
189
+ // Like the store: words in any order, ranked; every word before some of them.
190
+ const ranked = rank(all, q);
138
191
  const limit = Math.max(1, Number(body.pagination?.limit ?? 10));
139
192
  const offset = Number(body.pagination?.cursor ?? 0) || 0;
140
- const page = all.slice(offset, offset + limit);
141
- const hasNext = offset + limit < all.length;
193
+ const page = ranked.slice(offset, offset + limit);
194
+ const hasNext = offset + limit < ranked.length;
142
195
  const out = {
143
196
  ucp: envelope(ctx, 'catalog.search'),
144
- products: page.map((p) => product(ctx, p, market)),
145
- pagination: { has_next_page: hasNext, total_count: all.length, ...(hasNext ? { cursor: String(offset + limit) } : {}) },
197
+ products: page.map(({ product: p, match }) => product(ctx, p, market, asked, match)),
198
+ pagination: { has_next_page: hasNext, total_count: ranked.length, ...(hasNext ? { cursor: String(offset + limit) } : {}) },
146
199
  };
200
+ const categories = [...new Set(Object.values(applied(ctx).products).map((p) => p.category).filter(Boolean))].sort().join('; ');
201
+ const messages = [];
202
+ const words = queryWords(q);
203
+ if (!ranked.length)
204
+ messages.push({ type: 'info', code: 'no_results', content: `No products match${words.length ? `: searched the words "${words.join(' ')}" in titles, categories and descriptions, in any order` : ''}.${categories ? ` The store's categories: ${categories}.` : ''} Retry with fewer or other words, or with one of them in filters.categories.` });
205
+ else if (words.length > 1 && !ranked[0].match.all)
206
+ messages.push({ type: 'info', code: 'partial_match', content: `No product has every word of "${q}"; these have some of them (${words.join(', ')}), best first.${categories ? ` The store's categories: ${categories}.` : ''}` });
207
+ if (!body.context?.address_country && !body.context?.currency)
208
+ messages.push({ type: 'info', code: 'prices', content: `Prices are in ${market.currency} for the ${market.name} market (${market.countries.join(', ')}). Send context.address_country for another country's prices and shipping.` });
209
+ if (messages.length)
210
+ out.messages = messages;
147
211
  const policies = returnPolicy(ctx);
148
212
  if (policies.length)
149
213
  out.policies = policies;
@@ -202,7 +266,7 @@ function buildLines(ctx, market, requested, existing = []) {
202
266
  const price = toMinor(p.prices[market.currency], market.currency);
203
267
  const qty = quantity;
204
268
  const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
205
- const item = { id: p.sku, title: p.name, price };
269
+ const item = { id: p.sku, title: texts(p, lang(ctx)).name, price };
206
270
  item.image_url = imageUrl(ctx, p);
207
271
  lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
208
272
  });
@@ -570,6 +634,8 @@ function placeOrder(ctx, ck) {
570
634
  order.attribution = ck.attribution;
571
635
  if (ck.buyer)
572
636
  order._buyer = ck.buyer;
637
+ // What the merchant view of the order reads (stub/orders.ts): it lands placed, payment authorized.
638
+ Object.assign(order, { _placed_at: new Date().toISOString(), _market: ck._market, _cart_id: ck._cart_id, _codes: ck.discounts?.codes ?? [], _language: lang(ctx) });
573
639
  orders[orderId] = order;
574
640
  const webhook = platformWebhook(ctx);
575
641
  if (webhook)
@@ -4,6 +4,7 @@
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
+ import { baseLanguage, cookieLocale, COOKIE, localeOfCountry, localesOf, texts } from './locale.js';
7
8
  export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
8
9
  /** Black or white, whichever reads better on the accent. */
9
10
  function onAccent(hex) {
@@ -11,28 +12,126 @@ function onAccent(hex) {
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
- export function renderTheme(r, path, search) {
15
- const products = Object.values(r.applied?.products ?? {});
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 market = markets.find((m) => m.code === search.get('market')) ?? markets[0];
21
- const keep = market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
22
- const price = (p) => (market && p.prices[market.currency] !== undefined ? `${market.currency} ${p.prices[market.currency].toFixed(2)}` : '');
23
- const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
24
89
  const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
25
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) : '');
123
+ const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
26
124
  const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
27
- 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>`;
28
- 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>`;
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>`;
29
128
  let title = name;
30
129
  let body;
31
130
  const m = path.match(/^\/products\/([^/]+)$/);
32
131
  if (path === '/') {
33
132
  const groups = new Map();
34
133
  for (const p of products) {
35
- const top = p.category?.split(' > ')[0] ?? 'Other';
134
+ const top = texts(p, ll).category?.split(' > ')[0] ?? 'Other';
36
135
  groups.set(top, [...(groups.get(top) ?? []), p]);
37
136
  }
38
137
  const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
@@ -42,32 +141,22 @@ export function renderTheme(r, path, search) {
42
141
  const p = r.applied?.products[decodeURIComponent(m[1])];
43
142
  if (!p)
44
143
  return null;
45
- title = `${p.name} · ${name}`;
46
- 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>`;
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>`;
47
147
  }
48
148
  else
49
149
  return null;
50
- const html = `<!doctype html>
51
- <html lang="en">
52
- <head>
53
- <meta charset="utf-8">
54
- <meta name="viewport" content="width=device-width, initial-scale=1">
55
- <title>${esc(title)}</title>
56
- <meta name="description" content="${esc(tagline)}">
57
- <!-- c11r stub: a stand-in for the hosted theme. -->
58
- <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>
59
- </head>
60
- <body>
61
- <header><a href="/${keep}">${logo}</a><nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav></header>
62
- ${body}
63
- </body>
64
- </html>
65
- `;
66
- 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}` });
67
156
  }
68
157
  const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
69
158
  /** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
70
- export function renderTile(r, path) {
159
+ export function renderTile(r, path, search) {
71
160
  const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
72
161
  if (!m)
73
162
  return null;
@@ -87,7 +176,7 @@ export function renderTile(r, path) {
87
176
  const p = r.applied?.products[sku];
88
177
  if (!p)
89
178
  return null;
90
- 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>`;
91
180
  }
92
181
  return { status: 200, headers, body: svg };
93
182
  }
@@ -9,6 +9,7 @@ import { strip } from './db.js';
9
9
  import { json } from './http.js';
10
10
  import * as shop from './shopping.js';
11
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,21 +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>`);
311
315
  // Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
312
316
  const agentOnly = r.storefront_mode === 'agent-only';
313
317
  // Links shared while the store was hosted land on the agent page.
314
- if (agentOnly && /^\/(products|categories)\/./.test(path))
318
+ if (agentOnly && /^(\/[a-z]{2}-[a-z]{2})?\/(products|categories)\/./.test(path))
315
319
  return { status: 302, headers: { location: '/' }, body: '' };
316
320
  if (agentOnly && path === '/')
317
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>`);
318
322
  // A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
319
323
  if (!agentOnly && files['index.html'] === undefined) {
320
- const page = renderTheme(r, path, search);
324
+ const page = renderTheme(r, path, search, headers);
321
325
  if (page)
322
326
  return page;
323
327
  }
@@ -365,7 +369,7 @@ export function handleUcp(req, r) {
365
369
  return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
366
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>`);
367
371
  }
368
- return serveStorefront(r, path, req.url.searchParams);
372
+ return serveStorefront(r, path, req.url.searchParams, req.headers);
369
373
  }
370
374
  /** Lets the service read the bearer token for order authorization. */
371
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
- throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders test`, `orders list` or `orders show <id>`.');
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')