@agent-cards/commerce-sdk 0.14.0 → 0.16.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 +53 -5
- package/dist/ack.d.ts +26 -16
- package/dist/ack.js +64 -2
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +73 -8
- package/dist/buy-tool.js +304 -32
- package/dist/client.d.ts +58 -0
- package/dist/client.js +43 -2
- package/dist/definition.js +2 -0
- package/dist/direct.d.ts +5 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- package/dist/messages.js +4 -4
- package/dist/model-view.d.ts +59 -7
- package/dist/model-view.js +231 -31
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +3 -0
- package/dist/sentences.js +29 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +81 -0
- package/dist/texting.js +347 -0
- package/package.json +1 -1
package/dist/model-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SENTENCE_END, lastSentenceStart } from './sentences.js';
|
|
1
2
|
/** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
|
|
2
3
|
const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
|
|
3
4
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
@@ -59,6 +60,24 @@ export function stripLinks(text) {
|
|
|
59
60
|
.replace(/\n{3,}/g, '\n\n')
|
|
60
61
|
.trim();
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* A line that points at a link with a colon, the link after it on the same line or alone on the next: `here's the stool on
|
|
65
|
+
* Wayfair: https://www.wayfair.com/...`, one in angle brackets, or a bare address with a path or query (`vault.agentcard.sh?token=...`,
|
|
66
|
+
* never a bare `www.walmart.com`, which stripLinks keeps as the store's name). Every such
|
|
67
|
+
* link comes out of what the shopper reads (stripLinks), and the colon would then point at nothing. A Markdown link is not one:
|
|
68
|
+
* stripLinks keeps its label, which the shopper reads after the colon. A question mark that ends the sentence is not a query,
|
|
69
|
+
* as HOST_PATH reads it (`Buy from: glossier.com?`), and a page whose path ends in a bracket keeps it (`stool_(oak)`).
|
|
70
|
+
*/
|
|
71
|
+
const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(?:www\.)?(?:[\p{L}\p{N}-]+\.)+\p{L}{2,}(?:[/#]|\?(?![.,;:!?]*(?:[\s<>"'`)\]]|$))))[^\s<>"'`\]]*>?[ \t]*[.!]?[ \t]*$/gimu;
|
|
72
|
+
/**
|
|
73
|
+
* Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
|
|
74
|
+
* one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
|
|
75
|
+
* store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
|
|
76
|
+
* "here's the stool on Wayfair:" reached the shopper with nothing after it.
|
|
77
|
+
*/
|
|
78
|
+
export function replyWithoutLinks(reply, sent) {
|
|
79
|
+
return stripLinks(String(reply ?? '').replace(POINTS_AT_LINK, (_line, lead) => (sent ? `${lead}.` : '')));
|
|
80
|
+
}
|
|
62
81
|
/** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
|
|
63
82
|
function minorUnits(format) {
|
|
64
83
|
return format.resolvedOptions().maximumFractionDigits ?? 0;
|
|
@@ -135,13 +154,14 @@ export function cardKey(product) {
|
|
|
135
154
|
}
|
|
136
155
|
/**
|
|
137
156
|
* The key of the card a product gets, or null when it gets none: a product with a page at its store,
|
|
138
|
-
* or a flight option, which
|
|
139
|
-
* taps the flight they want (2026-10-20: three flights listed in text, and no card to tap)
|
|
157
|
+
* or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
|
|
158
|
+
* is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
|
|
159
|
+
* and a place's card is where the shopper reads whether it has room.
|
|
140
160
|
*/
|
|
141
161
|
export function productCardKey(product) {
|
|
142
162
|
if (typeof product.url === 'string' && product.url !== '')
|
|
143
163
|
return cardKey(product);
|
|
144
|
-
return product.flight ? pickId(product) : null;
|
|
164
|
+
return product.flight || product.availability ? pickId(product) : null;
|
|
145
165
|
}
|
|
146
166
|
function cartView(cart) {
|
|
147
167
|
return {
|
|
@@ -218,7 +238,11 @@ export function forModel(result) {
|
|
|
218
238
|
status,
|
|
219
239
|
next_action: result.next_action ?? null,
|
|
220
240
|
...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
|
|
221
|
-
|
|
241
|
+
...(result.choice ? { choice: {
|
|
242
|
+
question: result.choice.question === null ? null : stripLinks(result.choice.question),
|
|
243
|
+
groups: result.choice.groups.map((group) => ({ name: stripLinks(group.name), required: group.required, options: group.options.map((option) => `${stripLinks(option.label)}${option.price_delta_cents ? ` (+$${(option.price_delta_cents / 100).toFixed(2)})` : ''}`) })),
|
|
244
|
+
} } : {}),
|
|
245
|
+
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
|
|
222
246
|
cart: result.cart ? cartView(result.cart) : null,
|
|
223
247
|
carts: (result.carts ?? []).map(cartView),
|
|
224
248
|
products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
|
|
@@ -242,8 +266,73 @@ export function forModel(result) {
|
|
|
242
266
|
store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
|
|
243
267
|
? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
|
|
244
268
|
: null,
|
|
269
|
+
...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
|
|
245
270
|
};
|
|
246
271
|
}
|
|
272
|
+
/** The yes an answer went ahead on, as the model reads it, or null. */
|
|
273
|
+
function yesCarriedView(result) {
|
|
274
|
+
const yes = result.yes_carried;
|
|
275
|
+
if (!yes || typeof yes.said_yes_to_cents !== 'number' || typeof yes.total_cents !== 'number')
|
|
276
|
+
return null;
|
|
277
|
+
const said = money(yes.said_yes_to_cents, yes.currency);
|
|
278
|
+
const total = money(yes.total_cents, yes.currency);
|
|
279
|
+
return said && total ? { said_yes_to: said, total, total_changed: yes.said_yes_to_cents !== yes.total_cents, ...(yes.picked === true ? { picked: true } : {}) } : null;
|
|
280
|
+
}
|
|
281
|
+
/** The link each step's ask goes with. */
|
|
282
|
+
const STEP_LINK = { add_card: 'vault', approve: 'approval', store_login: 'store_login' };
|
|
283
|
+
/** The items of a cart in one line, such as `1 Chicken Salad` or `2 tacos and 1 horchata`. */
|
|
284
|
+
function itemsLine(cart) {
|
|
285
|
+
const lines = cart.items.map((line) => `${line.qty} ${line.name}`);
|
|
286
|
+
return lines.length <= 1 ? (lines[0] ?? '') : `${lines.slice(0, -1).join(', ')} and ${lines[lines.length - 1]}`;
|
|
287
|
+
}
|
|
288
|
+
/** A cart's total as the shopper reads it: `$17.43`, or `about $15.90 before tax and shipping or delivery` for an estimate. */
|
|
289
|
+
function totalLine(cart) {
|
|
290
|
+
if (!cart.total_is_estimate)
|
|
291
|
+
return cart.total;
|
|
292
|
+
const before = cart.total_leaves_out ? ` before ${cart.total_leaves_out.replace(/,.*$/, '')}` : '';
|
|
293
|
+
return `about ${cart.total}${before}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
|
|
297
|
+
* order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
|
|
298
|
+
* the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
|
|
299
|
+
* cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
|
|
300
|
+
* the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
|
|
301
|
+
* other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
|
|
302
|
+
* card or approval step has no cart to place, and when the answer also showed products.
|
|
303
|
+
*/
|
|
304
|
+
export function stepAsWritten(result) {
|
|
305
|
+
if (result.shopper_already_read_reply === true || result.link_sent !== true)
|
|
306
|
+
return null;
|
|
307
|
+
const next = result.next_action ?? '';
|
|
308
|
+
const kind = STEP_LINK[next];
|
|
309
|
+
const link = kind ? (result.links ?? []).find((l) => l.kind === kind && typeof l.url === 'string' && l.url !== '') : undefined;
|
|
310
|
+
if (!kind || !link)
|
|
311
|
+
return null;
|
|
312
|
+
const view = forModel(result);
|
|
313
|
+
const cart = view.cart;
|
|
314
|
+
// The line is about placing an order: an answer with no cart to place, such as the card link the shopper asked for while
|
|
315
|
+
// browsing, or one that also showed products the reply talks about, is the model's to word.
|
|
316
|
+
if ((!cart && next !== 'store_login') || view.products.length > 0)
|
|
317
|
+
return null;
|
|
318
|
+
const store = stripLinks(link.store ?? '') || cart?.store || 'the store';
|
|
319
|
+
const ask = next === 'add_card'
|
|
320
|
+
? 'add your card to place the order'
|
|
321
|
+
: next === 'store_login'
|
|
322
|
+
? `sign in to ${store} to place your order`
|
|
323
|
+
: `approve the payment to place your order${cart?.max_charge ? ` (up to ${cart.max_charge})` : ''}`;
|
|
324
|
+
const yes = view.yes_carried;
|
|
325
|
+
if (yes?.total_changed && cart) {
|
|
326
|
+
const total = cart.total_is_estimate ? `about ${cart.total}` : `${cart.total} with fees and tax`;
|
|
327
|
+
return `it's ${total} now. ${ask}`;
|
|
328
|
+
}
|
|
329
|
+
// Only the shopper's own words that built the cart get a line about it first; after a yes or a saved card, the ask is the whole message.
|
|
330
|
+
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
331
|
+
if (agentcards && result.confirm_sent !== true && !yes && cart && cart.items.length > 0 && next !== 'approve') {
|
|
332
|
+
return `${itemsLine(cart)} from ${cart.store}, ${totalLine(cart)}\n\n${ask}`;
|
|
333
|
+
}
|
|
334
|
+
return ask;
|
|
335
|
+
}
|
|
247
336
|
/**
|
|
248
337
|
* Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
|
|
249
338
|
* `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
|
|
@@ -252,6 +341,8 @@ export function forModel(result) {
|
|
|
252
341
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
253
342
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
254
343
|
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
344
|
+
* An answer whose next step is a link the call handed over reads as that step's fixed short line
|
|
345
|
+
* instead (`stepAsWritten`), whoever wrote the reply.
|
|
255
346
|
*
|
|
256
347
|
* ```ts
|
|
257
348
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
@@ -261,13 +352,20 @@ export function replyAsWritten(result) {
|
|
|
261
352
|
// The shopper already read every message of it through onText: handing it out again would say it twice.
|
|
262
353
|
if (result.shopper_already_read_reply === true)
|
|
263
354
|
return null;
|
|
355
|
+
// A step whose link the shopper already holds is asked for in the same short line every time, with no model.
|
|
356
|
+
const step = stepAsWritten(result);
|
|
357
|
+
if (step !== null)
|
|
358
|
+
return step;
|
|
264
359
|
// The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
|
|
265
360
|
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
266
361
|
const view = forModel(result);
|
|
267
362
|
const reply = view.reply.trim();
|
|
268
363
|
if (!agentcards || view.status !== 'needs_input' || reply === '')
|
|
269
364
|
return null;
|
|
270
|
-
const cards = [
|
|
365
|
+
const cards = [
|
|
366
|
+
...view.products.filter((product) => product.link_sent).map((product) => ({ name: product.name, price: product.price })),
|
|
367
|
+
...view.stores.filter((store) => store.link_sent).map((store) => ({ name: store.name })),
|
|
368
|
+
];
|
|
271
369
|
// A reply with nothing left once the cards' list comes out is your model's to word.
|
|
272
370
|
return withoutCardList(reply, cards) || null;
|
|
273
371
|
}
|
|
@@ -277,6 +375,15 @@ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
|
|
|
277
375
|
const PRICE = /[$£€]\s?\d/;
|
|
278
376
|
/** The place a sentence points at in a numbered list, such as `#8` or `number 3`, where `a #2 Phillips head` names a kind of thing. */
|
|
279
377
|
const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
378
|
+
/**
|
|
379
|
+
* A sentence that goes on to more of the list rather than recommending: `other 6-packs are ...`, `other options include ...`,
|
|
380
|
+
* `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
|
|
381
|
+
*/
|
|
382
|
+
const MORE_OF_LIST = /^[\s*_"'(]*(?:(?:and|plus|but|or)\s+)?(?:others?\b[^.!?:]{0,40}?\b(?:are|is|include|includes|would be)\b|there(?:'s|’s|\s+is|\s+are)\s+also\b|(?:you|we)\s+(?:could|can|might)\s+also\b)/i;
|
|
383
|
+
/** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
|
|
384
|
+
const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
|
|
385
|
+
/** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
|
|
386
|
+
const FILLER = new Set(['the', 'and', 'with', 'for', 'from', 'of', 'by', 'in', 'on', 'to', 'or', 'at', 'an', 'new', 'pack', 'count', 'set', 'each', 'size']);
|
|
280
387
|
/** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
|
|
281
388
|
const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|mb|w|mah|v|cm|mm|m|pack|pk|ct|count|pcs|pieces)\b/gi;
|
|
282
389
|
/**
|
|
@@ -286,6 +393,8 @@ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|m
|
|
|
286
393
|
const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
|
|
287
394
|
/** A size or a count written as one word, such as `24pack` or `10ft`. */
|
|
288
395
|
const SIZE = /^(\d+)(\p{L}+)$/u;
|
|
396
|
+
/** A size in inches, however a text writes it: `10 inch`, `10-inches`, `10in`, `10 in`. */
|
|
397
|
+
const INCHES = /(\d+)\s*-?\s*(?:inch(?:es)?|in)\b/gi;
|
|
289
398
|
/** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
|
|
290
399
|
function wordsOf(text) {
|
|
291
400
|
return text
|
|
@@ -295,29 +404,115 @@ function wordsOf(text) {
|
|
|
295
404
|
.filter((word) => word !== '')
|
|
296
405
|
.map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
|
|
297
406
|
}
|
|
407
|
+
/** An amount in hundredths, so `$26.46`, `from $26.46` and `26.46` all read 2646, and `¥1,331` reads 133100. */
|
|
408
|
+
function hundredths(whole, fraction) {
|
|
409
|
+
return Math.round(Number(`${whole.replace(/,/g, '')}${fraction ?? ''}`) * 100);
|
|
410
|
+
}
|
|
411
|
+
/** The amounts a text gives after a currency sign, in hundredths. */
|
|
412
|
+
function amountsIn(text) {
|
|
413
|
+
return new Set([...text.matchAll(AMOUNT)].map((match) => hundredths(match[1], match[2])));
|
|
414
|
+
}
|
|
298
415
|
/**
|
|
299
|
-
* The
|
|
300
|
-
* text
|
|
301
|
-
*
|
|
416
|
+
* The sizes among a text's words, by unit: `10ft` and `3ft` are two sizes in feet, `24pack` a count. Inches come from
|
|
417
|
+
* the text itself, because `wordsOf` leaves `10 inch` as the bare `10`, so the 10 inch skillet reads as one size in
|
|
418
|
+
* inches and the 12 inch one as another.
|
|
302
419
|
*/
|
|
303
|
-
function
|
|
304
|
-
const
|
|
305
|
-
for (const
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
const hits = head.filter((word) => said.has(word)).length;
|
|
310
|
-
if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
311
|
-
return index;
|
|
420
|
+
function sizesOf(words, text) {
|
|
421
|
+
const sizes = new Map();
|
|
422
|
+
for (const word of words) {
|
|
423
|
+
const [, count, unit] = /^(\d+)(\p{L}+)$/u.exec(word) ?? [];
|
|
424
|
+
if (count && unit)
|
|
425
|
+
sizes.set(unit, (sizes.get(unit) ?? new Set()).add(count));
|
|
312
426
|
}
|
|
313
|
-
|
|
427
|
+
for (const [, count] of text.matchAll(INCHES))
|
|
428
|
+
sizes.set('inch', (sizes.get('inch') ?? new Set()).add(count));
|
|
429
|
+
return sizes;
|
|
430
|
+
}
|
|
431
|
+
function readCards(shown) {
|
|
432
|
+
const named = shown
|
|
433
|
+
.map((card) => (typeof card === 'string' ? { name: card, price: null } : { name: String(card?.name ?? ''), price: card?.price ?? null }))
|
|
434
|
+
.map((card) => ({ name: card.name, words: wordsOf(card.name), price: card.price }))
|
|
435
|
+
.filter((card) => card.words.length > 0);
|
|
436
|
+
const holders = new Map();
|
|
437
|
+
for (const card of named)
|
|
438
|
+
for (const word of new Set(card.words))
|
|
439
|
+
holders.set(word, (holders.get(word) ?? 0) + 1);
|
|
440
|
+
const telling = (word) => word.length >= 3 && !/\d/.test(word) && !FILLER.has(word);
|
|
441
|
+
return named.map(({ name, words, price }) => {
|
|
442
|
+
const brand = words.find((word) => /\p{L}/u.test(word) && word.length >= 2 && !FILLER.has(word)) ?? null;
|
|
443
|
+
const amount = typeof price === 'string' ? /(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/.exec(price) : null;
|
|
444
|
+
return {
|
|
445
|
+
words,
|
|
446
|
+
head: words.slice(0, 6),
|
|
447
|
+
brand,
|
|
448
|
+
marks: new Set([...(brand ? [brand] : []), ...words.filter((word) => telling(word) && (holders.get(word) ?? 0) <= Math.max(1, named.length / 2))]),
|
|
449
|
+
price: amount ? hundredths(amount[1], amount[2]) : null,
|
|
450
|
+
sizes: sizesOf(words, name),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The cards a text names, by their indexes; empty when it names none. A text names a card in one of three ways:
|
|
456
|
+
*
|
|
457
|
+
* - By the first six words of the card's name: at least two of them, and most of them, in the text. A name of one word
|
|
458
|
+
* never counts in prose, where a generic word such as `soap` would. A size or a count among those words has to be in
|
|
459
|
+
* the text too, so the 3ft cable's line never reads as the 10ft card.
|
|
460
|
+
* - By the card's exact price with its brand or another word that tells it apart: `Planet Oat Extra Creamy ($22.89)`
|
|
461
|
+
* names the card `Planet Oat Extra Creamy Oatmilk, 32 Fl Oz (Pack of 6)` at $22.89. A price alone, as on a line of
|
|
462
|
+
* sizes, names nothing.
|
|
463
|
+
* - By its brand and two more of its first six words, when the text gives no price or gives the card's.
|
|
464
|
+
*
|
|
465
|
+
* The last two take a short name a reply writes for a long title, and neither counts when the text gives a size the card
|
|
466
|
+
* has in another number, such as 3ft for its 10ft. A text that gives prices, none of them the card's, names it only by
|
|
467
|
+
* the first six words, so a line for a product no card shows keeps its place beside the card of its brand.
|
|
468
|
+
* 2026-10-27, "Oat milk, shelf stable, a 6 pack": "Planet Oat Extra Creamy ($22.89)" shares too few of its title's first
|
|
469
|
+
* six words to match by them alone.
|
|
470
|
+
*/
|
|
471
|
+
function cardsNamed(text, cards, inList) {
|
|
472
|
+
const words = wordsOf(text);
|
|
473
|
+
// Each word with how often the text says it, so a name of one word twice over, `Miu Miu`, counts twice.
|
|
474
|
+
const said = new Map();
|
|
475
|
+
for (const word of words)
|
|
476
|
+
said.set(word, (said.get(word) ?? 0) + 1);
|
|
477
|
+
const amounts = amountsIn(text);
|
|
478
|
+
const sizes = sizesOf(words, text);
|
|
479
|
+
const found = [...cards.entries()].flatMap(([index, card]) => {
|
|
480
|
+
const { head } = card;
|
|
481
|
+
const shared = head.filter((word) => said.has(word));
|
|
482
|
+
const hit = new Set(shared);
|
|
483
|
+
if (head.every((word) => !/\d/.test(word) || said.has(word))) {
|
|
484
|
+
if (head.length === 1 ? inList && shared.length === 1 : shared.length >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
485
|
+
return [{ index, hit }];
|
|
486
|
+
}
|
|
487
|
+
const otherSize = [...card.sizes].some(([unit, counts]) => sizes.has(unit) && ![...counts].some((count) => sizes.get(unit).has(count)));
|
|
488
|
+
if (otherSize)
|
|
489
|
+
return [];
|
|
490
|
+
const ownPrice = card.price !== null && amounts.has(card.price);
|
|
491
|
+
if (ownPrice && [...card.marks].some((word) => said.has(word)))
|
|
492
|
+
return [{ index, hit }];
|
|
493
|
+
const otherPrice = amounts.size > 0 && !ownPrice;
|
|
494
|
+
const more = new Set(head.filter((word) => word !== card.brand && word.length >= 2 && !FILLER.has(word) && said.has(word)));
|
|
495
|
+
return !otherPrice && card.brand !== null && said.has(card.brand) && more.size >= 2 ? [{ index, hit }] : [];
|
|
496
|
+
});
|
|
497
|
+
// Cards whose names share their words read as several products where the text names one, as three Kirkland Signature
|
|
498
|
+
// Organic milks do for `Kirkland Signature Organic Almond Milk`: a card the text names in nothing but another card's
|
|
499
|
+
// words, each said once, is that card's mention, and counts for no product of its own. A text that says those words
|
|
500
|
+
// again names both products, as `Diamond Walnut Pieces ($5.99) and Diamond Walnut Pieces Halves ($7.99)` does.
|
|
501
|
+
const saidOnce = (hit) => [...hit].every((word) => said.get(word) === 1);
|
|
502
|
+
return found
|
|
503
|
+
.filter(({ hit }) => hit.size === 0 || !saidOnce(hit) || !found.some((other) => other.hit.size > hit.size && [...hit].every((word) => other.hit.has(word))))
|
|
504
|
+
.map(({ index }) => index);
|
|
314
505
|
}
|
|
315
506
|
/**
|
|
316
507
|
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
317
508
|
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
318
|
-
* points at a number the list gave (`the dishwand in #8`),
|
|
319
|
-
*
|
|
320
|
-
*
|
|
509
|
+
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
510
|
+
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
511
|
+
* further sentence that names another card. What is left is the reply around the list, such as `found these at
|
|
512
|
+
* amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
|
|
513
|
+
*
|
|
514
|
+
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
515
|
+
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
|
321
516
|
*
|
|
322
517
|
* ```ts
|
|
323
518
|
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
@@ -325,18 +520,18 @@ function cardNamed(text, cards, inList) {
|
|
|
325
520
|
* ```
|
|
326
521
|
*/
|
|
327
522
|
export function withoutCardList(reply, cardNames) {
|
|
328
|
-
const cards = cardNames
|
|
523
|
+
const cards = readCards(cardNames);
|
|
329
524
|
if (cards.length === 0)
|
|
330
525
|
return reply.trim();
|
|
331
526
|
// A text that opens with a card's name and gives its price is a list line, as a reply writes one without a bullet.
|
|
332
527
|
const opensCard = (text) => {
|
|
333
528
|
const words = wordsOf(text);
|
|
334
|
-
return PRICE.test(text) && cards.some((
|
|
529
|
+
return PRICE.test(text) && cards.some((card) => card.words.length >= 2 && card.words.slice(0, 3).every((word, i) => words[i] === word));
|
|
335
530
|
};
|
|
336
531
|
const lines = reply.split('\n');
|
|
337
532
|
const kind = lines.map((line) => {
|
|
338
533
|
if (LIST_ITEM.test(line))
|
|
339
|
-
return
|
|
534
|
+
return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
|
|
340
535
|
return opensCard(line) ? 'named' : 'kept';
|
|
341
536
|
});
|
|
342
537
|
const dropped = kind.map((of) => of !== 'kept');
|
|
@@ -363,21 +558,26 @@ export function withoutCardList(reply, cardNames) {
|
|
|
363
558
|
// `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
|
|
364
559
|
const list = listAfter(i);
|
|
365
560
|
if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
|
|
366
|
-
text = text.trim().
|
|
367
|
-
const sentences = text.split(
|
|
561
|
+
text = text.trim().slice(0, lastSentenceStart(text.trim())).trimEnd();
|
|
562
|
+
const sentences = text.split(SENTENCE_END).filter((sentence) => {
|
|
368
563
|
const place = LIST_NUMBER.exec(sentence);
|
|
369
564
|
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
370
565
|
return false;
|
|
371
|
-
const
|
|
566
|
+
const found = cardsNamed(sentence, cards, false);
|
|
372
567
|
// A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
|
|
373
568
|
if (kind[i] === 'named')
|
|
374
|
-
return
|
|
375
|
-
if (
|
|
569
|
+
return found.length === 0 && !opensCard(sentence);
|
|
570
|
+
if (found.length === 0)
|
|
376
571
|
return true;
|
|
572
|
+
// `other 6-packs are ...` and `there's also ...` recommend nothing: they are the list again, in a sentence.
|
|
573
|
+
if (MORE_OF_LIST.test(sentence))
|
|
574
|
+
return false;
|
|
575
|
+
const fresh = found.filter((card) => !named.has(card));
|
|
377
576
|
// A reply recommends one or two products in words; a sentence that names a third is the list again.
|
|
378
|
-
if (named.size
|
|
577
|
+
if (named.size + fresh.length > 2)
|
|
379
578
|
return false;
|
|
380
|
-
|
|
579
|
+
for (const card of fresh)
|
|
580
|
+
named.add(card);
|
|
381
581
|
return true;
|
|
382
582
|
});
|
|
383
583
|
if (kind[i] === 'named' && sentences.length === 0)
|
package/dist/order.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a shopper's order is, read from the messages Agentcard sends about it, for an app that draws
|
|
3
|
+
* an order tracker instead of a line of text. `readShopperMessage` puts it on `message.order`;
|
|
4
|
+
* `updateOrder` folds the next message about the same order into the one a tracker shows, and
|
|
5
|
+
* `arrivalText` writes the arrival line. No import reaches outside the package, so the browser entry
|
|
6
|
+
* carries this file too.
|
|
7
|
+
*/
|
|
8
|
+
/** The steps a tracker draws, left to right. An order that ends early reads `canceled` or `failed`. */
|
|
9
|
+
export declare const ORDER_STEPS: readonly ['placed', 'confirmed', 'preparing', 'on_the_way', 'delivered'];
|
|
10
|
+
/** One step a tracker draws. */
|
|
11
|
+
export type OrderPathStep = (typeof ORDER_STEPS)[number];
|
|
12
|
+
/** Where an order is: one of `ORDER_STEPS`, or `canceled` or `failed`, which end it before `delivered`. */
|
|
13
|
+
export type OrderStep = OrderPathStep | 'canceled' | 'failed';
|
|
14
|
+
/** One step of the tracker: the order went past it (`done`), is at it (`current`), or has not reached it (`upcoming`). */
|
|
15
|
+
export interface OrderStepState {
|
|
16
|
+
step: OrderPathStep;
|
|
17
|
+
/** The step's name as the tracker writes it, such as "Preparing". */
|
|
18
|
+
label: string;
|
|
19
|
+
state: 'done' | 'current' | 'upcoming';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An order as a tracker shows it. Every message about one order carries the same `orderId`, so an app
|
|
23
|
+
* keeps one tracker per `orderId` and passes each new message's order through `updateOrder`. A value
|
|
24
|
+
* Agentcard did not send is null; the package never guesses one.
|
|
25
|
+
*/
|
|
26
|
+
export interface ShopperOrder {
|
|
27
|
+
/** The store's id for the order, the same on every message about it. Null on a failure that placed no order. */
|
|
28
|
+
orderId: string | null;
|
|
29
|
+
/** The service the order went through, such as "DoorDash" or "Walmart". Null for an order across several retailers. */
|
|
30
|
+
merchantName: string | null;
|
|
31
|
+
/** The place the order comes from, such as the restaurant on DoorDash, when Agentcard names it. Show `merchantName` without it. */
|
|
32
|
+
storeName: string | null;
|
|
33
|
+
step: OrderStep;
|
|
34
|
+
/** The step's name as the tracker writes it: "Placed", "Accepted", "Preparing", "On the way", "Delivered", "Canceled" or "Failed". */
|
|
35
|
+
label: string;
|
|
36
|
+
/** The steps of `ORDER_STEPS` in order, each `done`, `current` or `upcoming`. After `canceled` or `failed` none is `current`. */
|
|
37
|
+
steps: OrderStepState[];
|
|
38
|
+
/** The store's own line for where the order is, such as "Preparing your order". */
|
|
39
|
+
title: string | null;
|
|
40
|
+
/** The store's own sentence under it, such as "Mendocino Farms is preparing your order." */
|
|
41
|
+
detail: string | null;
|
|
42
|
+
/** The earliest and the latest arrival the store estimates, as ISO 8601 timestamps. */
|
|
43
|
+
arrival: {
|
|
44
|
+
earliest: string | null;
|
|
45
|
+
latest: string | null;
|
|
46
|
+
} | null;
|
|
47
|
+
/** The delivery window the retailer quoted, in its own words, such as "Sat, Sep 6 - Tue, Sep 9". */
|
|
48
|
+
deliveryWindow: string | null;
|
|
49
|
+
/** The total in the currency's minor unit: 1789 is $17.89. */
|
|
50
|
+
totalCents: number | null;
|
|
51
|
+
/** The three-letter currency code, lowercase. */
|
|
52
|
+
currency: string | null;
|
|
53
|
+
/** The retailer's own order number, as on its confirmation, when it differs from `orderId`. */
|
|
54
|
+
retailerOrderId: string | null;
|
|
55
|
+
/** When the order was delivered, as an ISO 8601 timestamp. */
|
|
56
|
+
deliveredAt: string | null;
|
|
57
|
+
/** Why the order was canceled or failed, in the store's or Agentcard's words. */
|
|
58
|
+
reason: string | null;
|
|
59
|
+
/** When the order reached this step, as an ISO 8601 timestamp. */
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
}
|
|
62
|
+
/** The step an `order.updated` stage reads as, or null for a stage the package does not know. */
|
|
63
|
+
export declare function orderStepOf(stage: string | null | undefined): OrderStep | null;
|
|
64
|
+
/** The values a message names about its order, as the package reads them. */
|
|
65
|
+
export interface OrderFields {
|
|
66
|
+
orderId?: string;
|
|
67
|
+
merchantName?: string;
|
|
68
|
+
storeName?: string;
|
|
69
|
+
step: OrderStep;
|
|
70
|
+
/** For a canceled order, the step it was at before. */
|
|
71
|
+
reached?: OrderStep | null;
|
|
72
|
+
title?: string;
|
|
73
|
+
detail?: string;
|
|
74
|
+
arrivalEarliest?: string;
|
|
75
|
+
arrivalLatest?: string;
|
|
76
|
+
deliveryWindow?: string;
|
|
77
|
+
totalCents?: number;
|
|
78
|
+
currency?: string;
|
|
79
|
+
retailerOrderId?: string;
|
|
80
|
+
deliveredAt?: string;
|
|
81
|
+
reason?: string;
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
/** The order a message describes, from the values it names. */
|
|
85
|
+
export declare function orderFrom(fields: OrderFields): ShopperOrder;
|
|
86
|
+
/**
|
|
87
|
+
* The order a tracker shows once `next` arrives, for two messages about the same order. The order
|
|
88
|
+
* never moves back a step: Agentcard delivers each message at least once and in no set order, so a
|
|
89
|
+
* late `order.confirmed` after the driver picked the order up leaves the tracker at `on_the_way`. At
|
|
90
|
+
* the same step, the message with the later `updatedAt` wins. A value the shown message lacks, such
|
|
91
|
+
* as the total `order_placed` named or the restaurant `order.updated` named, comes from the other,
|
|
92
|
+
* and so does the store's line when both are at the same step. An arrival estimate holds until the
|
|
93
|
+
* order is delivered, canceled or failed.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* trackers.set(order.orderId, updateOrder(trackers.get(order.orderId), order));
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export declare function updateOrder(previous: ShopperOrder | null | undefined, next: ShopperOrder): ShopperOrder;
|
|
100
|
+
export interface ArrivalTextOptions {
|
|
101
|
+
/** The shopper's IANA time zone, such as `America/Los_Angeles`. Default: the runtime's own. */
|
|
102
|
+
timeZone?: string;
|
|
103
|
+
/** The locale the times are written in. Default: `en-US`. */
|
|
104
|
+
locale?: string;
|
|
105
|
+
/** Unix milliseconds that count as now, to tell a time today from one on another day. Default: the system clock. */
|
|
106
|
+
now?: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The line a tracker writes under the steps about when the order arrives, in the shopper's time zone,
|
|
110
|
+
* or null when Agentcard sent no time for it:
|
|
111
|
+
*
|
|
112
|
+
* - `Arriving 12:04 – 12:22 PM` from the store's estimate, with the weekday when it is not today;
|
|
113
|
+
* - `Arriving by 12:22 PM` when the estimate names only its latest time;
|
|
114
|
+
* - `Expected Sat, Sep 6 - Tue, Sep 9` from a retailer's delivery window;
|
|
115
|
+
* - `Delivered at 12:20 PM` once delivered.
|
|
116
|
+
*
|
|
117
|
+
* A canceled or failed order has no arrival line.
|
|
118
|
+
*/
|
|
119
|
+
export declare function arrivalText(order: Pick<ShopperOrder, 'step' | 'arrival' | 'deliveryWindow' | 'deliveredAt'>, options?: ArrivalTextOptions): string | null;
|
package/dist/order.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a shopper's order is, read from the messages Agentcard sends about it, for an app that draws
|
|
3
|
+
* an order tracker instead of a line of text. `readShopperMessage` puts it on `message.order`;
|
|
4
|
+
* `updateOrder` folds the next message about the same order into the one a tracker shows, and
|
|
5
|
+
* `arrivalText` writes the arrival line. No import reaches outside the package, so the browser entry
|
|
6
|
+
* carries this file too.
|
|
7
|
+
*/
|
|
8
|
+
/** The steps a tracker draws, left to right. An order that ends early reads `canceled` or `failed`. */
|
|
9
|
+
export const ORDER_STEPS = ['placed', 'confirmed', 'preparing', 'on_the_way', 'delivered'];
|
|
10
|
+
const LABELS = {
|
|
11
|
+
placed: 'Placed',
|
|
12
|
+
confirmed: 'Accepted',
|
|
13
|
+
preparing: 'Preparing',
|
|
14
|
+
on_the_way: 'On the way',
|
|
15
|
+
delivered: 'Delivered',
|
|
16
|
+
canceled: 'Canceled',
|
|
17
|
+
failed: 'Failed',
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The step each delivery stage of `order.updated` reads as. The store accepting the order is
|
|
21
|
+
* `confirmed`; a driver being found, waiting at the store or the order being ready is `preparing`;
|
|
22
|
+
* the order picked up or nearby is `on_the_way`.
|
|
23
|
+
*/
|
|
24
|
+
const STAGE_STEPS = {
|
|
25
|
+
placed: 'placed',
|
|
26
|
+
confirmed: 'confirmed',
|
|
27
|
+
courier_assigned: 'preparing',
|
|
28
|
+
courier_at_store: 'preparing',
|
|
29
|
+
ready_for_pickup: 'preparing',
|
|
30
|
+
picked_up: 'on_the_way',
|
|
31
|
+
arriving: 'on_the_way',
|
|
32
|
+
delivered: 'delivered',
|
|
33
|
+
canceled: 'canceled',
|
|
34
|
+
};
|
|
35
|
+
/** The step an `order.updated` stage reads as, or null for a stage the package does not know. */
|
|
36
|
+
export function orderStepOf(stage) {
|
|
37
|
+
return (stage && STAGE_STEPS[stage]) || null;
|
|
38
|
+
}
|
|
39
|
+
const RANK = { placed: 0, confirmed: 1, preparing: 2, on_the_way: 3, delivered: 4, canceled: 5, failed: 5 };
|
|
40
|
+
/**
|
|
41
|
+
* The tracker's steps. An order at a step has passed every step before it. A canceled order has
|
|
42
|
+
* passed the step it was canceled at, `reached`, and a failed one passed none.
|
|
43
|
+
*/
|
|
44
|
+
function stepsOf(step, reached) {
|
|
45
|
+
const ended = step === 'canceled' || step === 'failed';
|
|
46
|
+
const at = ended ? (reached && reached !== 'canceled' && reached !== 'failed' ? RANK[reached] : step === 'canceled' ? 0 : -1) : RANK[step];
|
|
47
|
+
return ORDER_STEPS.map((s) => ({
|
|
48
|
+
step: s,
|
|
49
|
+
label: LABELS[s],
|
|
50
|
+
state: RANK[s] < at || (ended && RANK[s] === at) ? 'done' : RANK[s] === at ? 'current' : 'upcoming',
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
/** The order a message describes, from the values it names. */
|
|
54
|
+
export function orderFrom(fields) {
|
|
55
|
+
const earliest = fields.arrivalEarliest ?? null;
|
|
56
|
+
const latest = fields.arrivalLatest ?? null;
|
|
57
|
+
return {
|
|
58
|
+
orderId: fields.orderId ?? null,
|
|
59
|
+
merchantName: fields.merchantName ?? null,
|
|
60
|
+
storeName: fields.storeName ?? null,
|
|
61
|
+
step: fields.step,
|
|
62
|
+
label: LABELS[fields.step],
|
|
63
|
+
steps: stepsOf(fields.step, fields.reached ?? null),
|
|
64
|
+
title: fields.title ?? null,
|
|
65
|
+
detail: fields.detail ?? null,
|
|
66
|
+
arrival: earliest || latest ? { earliest, latest } : null,
|
|
67
|
+
deliveryWindow: fields.deliveryWindow ?? null,
|
|
68
|
+
totalCents: fields.totalCents ?? null,
|
|
69
|
+
currency: fields.currency ?? null,
|
|
70
|
+
retailerOrderId: fields.retailerOrderId ?? null,
|
|
71
|
+
deliveredAt: fields.deliveredAt ?? null,
|
|
72
|
+
reason: fields.reason ?? null,
|
|
73
|
+
updatedAt: fields.updatedAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Values that stay true of an order whatever step it reaches. */
|
|
77
|
+
const LASTING = ['orderId', 'merchantName', 'storeName', 'totalCents', 'currency', 'retailerOrderId', 'deliveryWindow'];
|
|
78
|
+
/**
|
|
79
|
+
* The order a tracker shows once `next` arrives, for two messages about the same order. The order
|
|
80
|
+
* never moves back a step: Agentcard delivers each message at least once and in no set order, so a
|
|
81
|
+
* late `order.confirmed` after the driver picked the order up leaves the tracker at `on_the_way`. At
|
|
82
|
+
* the same step, the message with the later `updatedAt` wins. A value the shown message lacks, such
|
|
83
|
+
* as the total `order_placed` named or the restaurant `order.updated` named, comes from the other,
|
|
84
|
+
* and so does the store's line when both are at the same step. An arrival estimate holds until the
|
|
85
|
+
* order is delivered, canceled or failed.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* trackers.set(order.orderId, updateOrder(trackers.get(order.orderId), order));
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function updateOrder(previous, next) {
|
|
92
|
+
if (!previous)
|
|
93
|
+
return next;
|
|
94
|
+
const diff = RANK[next.step] - RANK[previous.step];
|
|
95
|
+
const newer = diff !== 0 ? diff > 0 : !(Date.parse(next.updatedAt) < Date.parse(previous.updatedAt));
|
|
96
|
+
const [shown, other] = newer ? [next, previous] : [previous, next];
|
|
97
|
+
const merged = { ...shown };
|
|
98
|
+
for (const key of LASTING)
|
|
99
|
+
if (merged[key] === null)
|
|
100
|
+
merged[key] = other[key];
|
|
101
|
+
if (shown.step === other.step) {
|
|
102
|
+
merged.title ??= other.title;
|
|
103
|
+
merged.detail ??= other.detail;
|
|
104
|
+
}
|
|
105
|
+
// An arrival estimate holds until the order arrives, whichever step quoted it.
|
|
106
|
+
if (shown.step !== 'delivered' && shown.step !== 'canceled' && shown.step !== 'failed')
|
|
107
|
+
merged.arrival ??= other.arrival;
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
function validDate(value) {
|
|
111
|
+
if (!value)
|
|
112
|
+
return null;
|
|
113
|
+
const date = new Date(value);
|
|
114
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The line a tracker writes under the steps about when the order arrives, in the shopper's time zone,
|
|
118
|
+
* or null when Agentcard sent no time for it:
|
|
119
|
+
*
|
|
120
|
+
* - `Arriving 12:04 – 12:22 PM` from the store's estimate, with the weekday when it is not today;
|
|
121
|
+
* - `Arriving by 12:22 PM` when the estimate names only its latest time;
|
|
122
|
+
* - `Expected Sat, Sep 6 - Tue, Sep 9` from a retailer's delivery window;
|
|
123
|
+
* - `Delivered at 12:20 PM` once delivered.
|
|
124
|
+
*
|
|
125
|
+
* A canceled or failed order has no arrival line.
|
|
126
|
+
*/
|
|
127
|
+
export function arrivalText(order, options = {}) {
|
|
128
|
+
const locale = options.locale ?? 'en-US';
|
|
129
|
+
const now = new Date(options.now ?? Date.now());
|
|
130
|
+
const time = (date) => {
|
|
131
|
+
const day = (d) => new Intl.DateTimeFormat(locale, { timeZone: options.timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }).format(d);
|
|
132
|
+
const today = day(date) === day(now);
|
|
133
|
+
return new Intl.DateTimeFormat(locale, { timeZone: options.timeZone, hour: 'numeric', minute: '2-digit', ...(today ? {} : { weekday: 'short' }) });
|
|
134
|
+
};
|
|
135
|
+
if (order.step === 'canceled' || order.step === 'failed')
|
|
136
|
+
return null;
|
|
137
|
+
if (order.step === 'delivered') {
|
|
138
|
+
const at = validDate(order.deliveredAt);
|
|
139
|
+
return at ? `Delivered at ${time(at).format(at)}` : 'Delivered';
|
|
140
|
+
}
|
|
141
|
+
const earliest = validDate(order.arrival?.earliest);
|
|
142
|
+
const latest = validDate(order.arrival?.latest);
|
|
143
|
+
if (earliest && latest && earliest.getTime() < latest.getTime()) {
|
|
144
|
+
const format = time(earliest);
|
|
145
|
+
const range = typeof format.formatRange === 'function' ? format.formatRange(earliest, latest) : `${format.format(earliest)} – ${format.format(latest)}`;
|
|
146
|
+
return `Arriving ${range}`;
|
|
147
|
+
}
|
|
148
|
+
if (latest)
|
|
149
|
+
return `Arriving by ${time(latest).format(latest)}`;
|
|
150
|
+
if (earliest)
|
|
151
|
+
return `Arriving around ${time(earliest).format(earliest)}`;
|
|
152
|
+
if (order.deliveryWindow)
|
|
153
|
+
return `Expected ${order.deliveryWindow}`;
|
|
154
|
+
return null;
|
|
155
|
+
}
|