@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.
- package/README.md +28 -7
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +149 -20
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
118
|
-
description:
|
|
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 ?? '').
|
|
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
|
-
|
|
136
|
-
|
|
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 =
|
|
141
|
-
const hasNext = offset + limit <
|
|
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:
|
|
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)
|
package/dist/api/stub/theme.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
-
|
|
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 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
|
|
28
|
-
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>`;
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
}
|
package/dist/api/stub/ucp.js
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
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')
|