@curless/hotel-storefront 0.18.3 → 0.19.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/dist/__tests__/demo-hotels.test.js +21 -2
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/server.test.js +17 -3
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +282 -5
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +12 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +7 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +24 -7
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +24 -2
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +313 -95
- package/dist/demo-hotels.js.map +1 -1
- package/dist/legal.d.ts +1 -1
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +31 -10
- package/dist/legal.js.map +1 -1
- package/dist/limits.d.ts +12 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +47 -0
- package/dist/limits.js.map +1 -0
- package/dist/members.d.ts +9 -0
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +17 -0
- package/dist/members.js.map +1 -1
- package/dist/money.d.ts +6 -1
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +6 -1
- package/dist/money.js.map +1 -1
- package/dist/present.d.ts +2 -1
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +3 -2
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts +8 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -4
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts +2 -2
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +2 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +39 -13
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +376 -95
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +12 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -71,7 +71,7 @@ export const buildTools = (n, v) => {
|
|
|
71
71
|
return [
|
|
72
72
|
{
|
|
73
73
|
name: n.listHotels,
|
|
74
|
-
description: `${v.brand} ${v.nounPlural}, shown as a card with a
|
|
74
|
+
description: `${v.brand} ${v.nounPlural}, shown as a card. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\` and \`breakfast\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
|
|
75
75
|
annotations: reads(`Browse ${v.brand} ${v.nounPlural}`, open),
|
|
76
76
|
inputSchema: {
|
|
77
77
|
type: 'object',
|
|
@@ -80,15 +80,23 @@ export const buildTools = (n, v) => {
|
|
|
80
80
|
type: 'string',
|
|
81
81
|
description: "Free text over the name, city, area, description and amenities - the guest's own words",
|
|
82
82
|
},
|
|
83
|
-
city: {
|
|
83
|
+
city: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'One city or one country, e.g. "Hong Kong" or "Japan"',
|
|
86
|
+
},
|
|
87
|
+
...stayProps,
|
|
88
|
+
rooms: {
|
|
89
|
+
type: 'number',
|
|
90
|
+
description: `With dates: how many rooms of one type the party needs, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
|
|
91
|
+
},
|
|
84
92
|
sort: {
|
|
85
93
|
type: 'string',
|
|
86
94
|
enum: ['group', 'price-low', 'price-high', 'rating'],
|
|
87
|
-
description: `Default \`group\`: the cities the group has most ${v.nounPlural} in lead the list. A price order holds only where the matches share one currency
|
|
95
|
+
description: `Default \`group\`: the cities the group has most ${v.nounPlural} in lead the list. A price order - by the stay's price with dates, the from-price without - holds only where the matches share one currency; across currencies it is dropped and the answer says which currencies they are`,
|
|
88
96
|
},
|
|
89
97
|
maxNightly: {
|
|
90
98
|
type: 'number',
|
|
91
|
-
description: 'A ceiling on the from-price
|
|
99
|
+
description: 'A ceiling on the price of a night - of the stay with dates, the from-price without - in the currency the matches are quoted in. Across currencies it is dropped and the answer says so',
|
|
92
100
|
},
|
|
93
101
|
limit: {
|
|
94
102
|
type: 'number',
|
|
@@ -153,7 +161,11 @@ export const buildTools = (n, v) => {
|
|
|
153
161
|
...stayProps,
|
|
154
162
|
booking: {
|
|
155
163
|
type: 'string',
|
|
156
|
-
description: `A booking's confirmation code. The answer is then that booking's ${v.noun}, its add-ons for those nights and how many of each the booking holds
|
|
164
|
+
description: `A booking's confirmation code. The answer is then that booking's ${v.noun}, its add-ons for those nights and how many of each the booking holds`,
|
|
165
|
+
},
|
|
166
|
+
email: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
description: 'The email the booking was made with, when it was made elsewhere',
|
|
157
169
|
},
|
|
158
170
|
},
|
|
159
171
|
},
|
|
@@ -227,7 +239,7 @@ export const buildTools = (n, v) => {
|
|
|
227
239
|
},
|
|
228
240
|
rememberRequests: {
|
|
229
241
|
type: 'boolean',
|
|
230
|
-
description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet
|
|
242
|
+
description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet or connecting room; an accessible room is asked for on each stay and is not kept',
|
|
231
243
|
},
|
|
232
244
|
},
|
|
233
245
|
required: ['name', 'email'],
|
|
@@ -471,7 +483,7 @@ export const buildTools = (n, v) => {
|
|
|
471
483
|
},
|
|
472
484
|
{
|
|
473
485
|
name: n.forgetPreferences,
|
|
474
|
-
description: `Forgets the room preferences saved on the signed-in ${v.brand} membership - a bed type, a floor, a quiet or
|
|
486
|
+
description: `Forgets the room preferences saved on the signed-in ${v.brand} membership - a bed type, a floor, a quiet or connecting room. Bookings already made keep what they asked for.`,
|
|
475
487
|
annotations: {
|
|
476
488
|
title: 'Forget my saved preferences',
|
|
477
489
|
readOnlyHint: false,
|
|
@@ -816,6 +828,10 @@ const quoteForCard = (quote) => ({
|
|
|
816
828
|
stay: l.stay,
|
|
817
829
|
nights: l.nights,
|
|
818
830
|
quantity: l.quantity,
|
|
831
|
+
// How many were ORDERED, beside how many units that comes to: one extra
|
|
832
|
+
// bed over three nights is quantity 3 and ordered 1, and the card's
|
|
833
|
+
// stepper read 3 and sent 3 ± 1 as the new count (2026-09-28 audit).
|
|
834
|
+
...(l.ordered !== undefined ? { ordered: l.ordered } : {}),
|
|
819
835
|
unit: formatMoney(l.unit),
|
|
820
836
|
total: formatMoney(l.total),
|
|
821
837
|
refundable: l.refundable,
|
|
@@ -839,7 +855,7 @@ const tripText = (quote, demo) => {
|
|
|
839
855
|
return 'The trip is empty.';
|
|
840
856
|
const lines = quote.lines.map((l) => l.kind === 'room'
|
|
841
857
|
? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} nights) — ${formatMoney(l.total)}`
|
|
842
|
-
: `- ${l.quantity}× ${l.title} — ${formatMoney(l.total)}`);
|
|
858
|
+
: `- ${l.ordered ?? l.quantity}× ${l.title}${l.ordered !== undefined && l.quantity !== undefined && l.quantity !== l.ordered ? ` (${l.quantity} in all, over the stay)` : ''} — ${formatMoney(l.total)}`);
|
|
843
859
|
return [
|
|
844
860
|
...lines,
|
|
845
861
|
`Taxes and fees ${formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
|
|
@@ -1000,23 +1016,30 @@ const widen = (ask) => {
|
|
|
1000
1016
|
};
|
|
1001
1017
|
/** What a change would do, in one sentence - the nights, and the add-on when it sets one. */
|
|
1002
1018
|
const changeSentence = (p) => {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1019
|
+
// ⚠️ EACH THING THAT CHANGES, NAMED AS WHAT IT IS. Two rooms down to one
|
|
1020
|
+
// was said as "would move to" the nights it already held, and an add-on set
|
|
1021
|
+
// to the count it already had as "take it off" (2026-09-28 audit).
|
|
1022
|
+
const to = p.to;
|
|
1023
|
+
const rooms = (n) => `${n} room${n === 1 ? '' : 's'}`;
|
|
1008
1024
|
const a = p.addOn;
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1025
|
+
const parts = [
|
|
1026
|
+
to && (to.stay.checkIn !== p.from.stay.checkIn || to.stay.checkOut !== p.from.stay.checkOut)
|
|
1027
|
+
? `move to ${to.stay.checkIn} - ${to.stay.checkOut}`
|
|
1028
|
+
: '',
|
|
1029
|
+
to && to.roomName !== p.from.roomName ? `change the room to ${to.roomName}` : '',
|
|
1030
|
+
to && to.rooms !== p.from.rooms ? `go from ${rooms(p.from.rooms)} to ${rooms(to.rooms)}` : '',
|
|
1031
|
+
!a || a.from === a.to
|
|
1032
|
+
? ''
|
|
1033
|
+
: a.to === 0
|
|
1034
|
+
? `take ${a.name} off`
|
|
1035
|
+
: a.from === 0
|
|
1036
|
+
? `add ${a.to}× ${a.name}`
|
|
1037
|
+
: `change ${a.name} from ${a.from} to ${a.to}`,
|
|
1038
|
+
].filter(Boolean);
|
|
1039
|
+
const said = parts.length > 1
|
|
1040
|
+
? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`
|
|
1041
|
+
: (parts[0] ?? 'change');
|
|
1042
|
+
return `${p.confirmationCode} would ${said}.`;
|
|
1020
1043
|
};
|
|
1021
1044
|
/**
|
|
1022
1045
|
* How much of a plan is real, in the one sentence the model is told to repeat:
|
|
@@ -1135,6 +1158,57 @@ const identity = (deps, ctx, key, args = {}) => {
|
|
|
1135
1158
|
...(member ? { member } : {}),
|
|
1136
1159
|
};
|
|
1137
1160
|
};
|
|
1161
|
+
/**
|
|
1162
|
+
* What the last preview of each booking's cancellation or change showed, per
|
|
1163
|
+
* conversation.
|
|
1164
|
+
*
|
|
1165
|
+
* ⚠️ A CONFIRMATION CONFIRMS WHAT WAS SHOWN. A cancellation previewed at no
|
|
1166
|
+
* charge and confirmed after the free window closed was cancelled for a
|
|
1167
|
+
* night's rate nobody had been shown; a `confirmed` with no preview at all was
|
|
1168
|
+
* cancelled on the spot (2026-09-28 audit). Now a confirmation goes through
|
|
1169
|
+
* only when the fee - or the change's new total - is the one last shown in
|
|
1170
|
+
* this conversation; otherwise it answers with the preview again.
|
|
1171
|
+
*/
|
|
1172
|
+
const shownPreviews = new WeakMap();
|
|
1173
|
+
const MAX_SHOWN = 5_000;
|
|
1174
|
+
const previewsOf = (deps) => {
|
|
1175
|
+
let m = shownPreviews.get(deps);
|
|
1176
|
+
if (!m) {
|
|
1177
|
+
m = new Map();
|
|
1178
|
+
shownPreviews.set(deps, m);
|
|
1179
|
+
}
|
|
1180
|
+
return m;
|
|
1181
|
+
};
|
|
1182
|
+
const remember = (deps, k, amount) => {
|
|
1183
|
+
const m = previewsOf(deps);
|
|
1184
|
+
m.delete(k);
|
|
1185
|
+
m.set(k, amount);
|
|
1186
|
+
while (m.size > MAX_SHOWN) {
|
|
1187
|
+
const oldest = m.keys().next().value;
|
|
1188
|
+
if (oldest === undefined)
|
|
1189
|
+
break;
|
|
1190
|
+
m.delete(oldest);
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
/** Said when a lookup has missed too often - see limits.ts. */
|
|
1194
|
+
const TOO_MANY_TRIES = 'Too many codes and emails that did not match, so looking a booking up by email is paused for a quarter of an hour. Check the code and the email on the confirmation, then try again.';
|
|
1195
|
+
/**
|
|
1196
|
+
* A booking lookup, with any email in it counted as a guess.
|
|
1197
|
+
*
|
|
1198
|
+
* ⚠️ A CODE AND AN EMAIL ARE ONLY A PAIR WHEN SOMETHING COUNTS THE MISSES. A
|
|
1199
|
+
* miss that carried an email is counted against the conversation AND the
|
|
1200
|
+
* email; after a few, lookups by email wait (limits.ts). A lookup of this
|
|
1201
|
+
* conversation's own booking, with no email, is never counted and never waits.
|
|
1202
|
+
*/
|
|
1203
|
+
const guarded = async (deps, key, who, run) => {
|
|
1204
|
+
const keys = who.email ? [`lookup|${key}`, `email|${who.email.trim().toLowerCase()}`] : [];
|
|
1205
|
+
if (keys.length && deps.misses?.blocked(keys))
|
|
1206
|
+
return { found: null, tooMany: true };
|
|
1207
|
+
const found = await run();
|
|
1208
|
+
if (!found && keys.length)
|
|
1209
|
+
deps.misses?.miss(keys);
|
|
1210
|
+
return { found, tooMany: false };
|
|
1211
|
+
};
|
|
1138
1212
|
/**
|
|
1139
1213
|
* The trip priced one currency at a time, in the order the currencies first
|
|
1140
1214
|
* appear in it.
|
|
@@ -1215,7 +1289,9 @@ const currentTrip = async (deps, key, who) => {
|
|
|
1215
1289
|
// never a sum.
|
|
1216
1290
|
groups: shaped.map((p, i) => ({ ...p, currency: parts[i]?.total.currency })),
|
|
1217
1291
|
currencies: parts.map((p) => p.total.currency),
|
|
1218
|
-
|
|
1292
|
+
// Side by side with a dot, not a plus: "HK$8,588 + ¥152,550" reads as
|
|
1293
|
+
// a sum nobody can do.
|
|
1294
|
+
total: shaped.map((p) => p.total).join(' · '),
|
|
1219
1295
|
...needs,
|
|
1220
1296
|
...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
|
|
1221
1297
|
count: lines.length,
|
|
@@ -1317,7 +1393,9 @@ const suggestionsFor = async (deps, d) => {
|
|
|
1317
1393
|
const nearby = await deps.backend.searchHotels({ city: hotel.city, limit: 2 });
|
|
1318
1394
|
if (nearby.total > 1)
|
|
1319
1395
|
out.push({
|
|
1320
|
-
label:
|
|
1396
|
+
// The label IS what is sent: a chip that sends more than it says is
|
|
1397
|
+
// a chip that says something else in the guest's name.
|
|
1398
|
+
label: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
|
|
1321
1399
|
say: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
|
|
1322
1400
|
});
|
|
1323
1401
|
}
|
|
@@ -1338,7 +1416,7 @@ const suggestionsFor = async (deps, d) => {
|
|
|
1338
1416
|
}
|
|
1339
1417
|
if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
|
|
1340
1418
|
out.push({
|
|
1341
|
-
label: 'Put
|
|
1419
|
+
label: 'Put the stays on this plan in the trip',
|
|
1342
1420
|
say: 'Put the stays on this plan in the trip',
|
|
1343
1421
|
});
|
|
1344
1422
|
if (view === 'booking') {
|
|
@@ -1395,11 +1473,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1395
1473
|
sort,
|
|
1396
1474
|
maxNightly: ceiling,
|
|
1397
1475
|
stay: asStay(args),
|
|
1398
|
-
|
|
1476
|
+
// Clamped here, so what is said and echoed is what was priced: "9 rooms"
|
|
1477
|
+
// was said over prices for five.
|
|
1478
|
+
rooms: typeof args.rooms === 'number' && Number.isFinite(args.rooms)
|
|
1479
|
+
? Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(args.rooms)))
|
|
1480
|
+
: undefined,
|
|
1399
1481
|
filters: filtersOf(args),
|
|
1400
1482
|
};
|
|
1401
1483
|
const who = identity(deps, ctx, key, args);
|
|
1402
1484
|
let answer = await deps.backend.searchHotels(ask, who);
|
|
1485
|
+
// A brand as the data spells it, so its chip shows as ON and can be turned
|
|
1486
|
+
// off: `brand: 'kerry'` filtered, and then no chip matched it.
|
|
1487
|
+
const brandAsked = ask.filters?.brand?.toLowerCase();
|
|
1488
|
+
const brandNamed = answer.refine?.brands.find((b) => b.name.toLowerCase() === brandAsked)?.name;
|
|
1489
|
+
if (ask.filters && brandNamed)
|
|
1490
|
+
ask.filters = { ...ask.filters, brand: brandNamed };
|
|
1403
1491
|
// The search the answer actually came from - the widened one, when it was
|
|
1404
1492
|
// widened - so its next page is the next page of THAT.
|
|
1405
1493
|
let asked = ask;
|
|
@@ -1419,20 +1507,29 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1419
1507
|
// comment was written to forbid, committed by the code under it.
|
|
1420
1508
|
// `overBudget` is how the seam says the place and the words did match.
|
|
1421
1509
|
let widened;
|
|
1422
|
-
// ⚠️ A FILTER THAT EMPTIED THE LIST IS NOT A SEARCH THAT
|
|
1423
|
-
// for the same reason as the ceiling
|
|
1424
|
-
|
|
1425
|
-
|
|
1510
|
+
// ⚠️ A FILTER OR A CEILING THAT EMPTIED THE LIST IS NOT A SEARCH THAT
|
|
1511
|
+
// FAILED either, for the same reason as the ceiling above: the place
|
|
1512
|
+
// matched. What matched the place and the words, before either:
|
|
1513
|
+
const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
|
|
1514
|
+
if (placedCount(answer) === 0) {
|
|
1426
1515
|
const wider = widen(ask);
|
|
1427
1516
|
if (wider) {
|
|
1428
1517
|
const second = await deps.backend.searchHotels(wider.ask, who);
|
|
1429
|
-
|
|
1430
|
-
|
|
1518
|
+
// Found when the wider PLACE matched - a wider search emptied by the
|
|
1519
|
+
// same ceiling or filter is still the place, and "Hong Kong found
|
|
1520
|
+
// nothing either" was said of four hotels over the ceiling.
|
|
1521
|
+
widened = { from: wider.from, to: wider.to, found: placedCount(second) > 0 };
|
|
1522
|
+
if (widened.found) {
|
|
1431
1523
|
answer = second;
|
|
1432
1524
|
asked = { ...ask, ...wider.ask };
|
|
1433
1525
|
}
|
|
1434
1526
|
}
|
|
1435
1527
|
}
|
|
1528
|
+
const filteredEmpty = !!asked.filters &&
|
|
1529
|
+
answer.total === 0 &&
|
|
1530
|
+
placedCount(answer) > 0 &&
|
|
1531
|
+
(answer.beforeCeiling ?? 0) === 0;
|
|
1532
|
+
const ceilingEmpty = answer.total === 0 && (answer.beforeCeiling ?? 0) > 0;
|
|
1436
1533
|
// The ceiling again on this side of the seam. MAX_PAGE is part of the
|
|
1437
1534
|
// backend contract, but "the next backend honours it" is not something the
|
|
1438
1535
|
// card can be built on - and the cost of it being wrong is the whole group
|
|
@@ -1486,6 +1583,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1486
1583
|
...(ask.rooms && ask.rooms > 1 ? { rooms: ask.rooms } : {}),
|
|
1487
1584
|
}
|
|
1488
1585
|
: {};
|
|
1586
|
+
// ⚠️ THE SEARCH AS ASKED, `all` INCLUDED. The echo dropped `all`, so every
|
|
1587
|
+
// re-query the card made from a "Browse all" list - new dates, the default
|
|
1588
|
+
// order, clearing a filter - met no place, no words and no filter, and
|
|
1589
|
+
// came back as the destinations page (2026-09-28 audit). Built once, for
|
|
1590
|
+
// the list and for the list a filter or a ceiling emptied.
|
|
1591
|
+
const listAll = args.all === true;
|
|
1592
|
+
const echo = (applied) => ({
|
|
1593
|
+
...(asked.query ? { query: asked.query } : {}),
|
|
1594
|
+
...(asked.city ? { city: asked.city } : {}),
|
|
1595
|
+
...(applied !== 'group' ? { sort: applied } : {}),
|
|
1596
|
+
...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
|
|
1597
|
+
...(asked.filters ?? {}),
|
|
1598
|
+
...(listAll ? { all: true } : {}),
|
|
1599
|
+
...stayArgs,
|
|
1600
|
+
});
|
|
1489
1601
|
const namedCountry = !!asked.city &&
|
|
1490
1602
|
facets?.countries.length === 1 &&
|
|
1491
1603
|
facets.countries[0]?.name.toLowerCase() === asked.city.toLowerCase();
|
|
@@ -1495,8 +1607,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1495
1607
|
sort === 'group' &&
|
|
1496
1608
|
!asked.maxNightly &&
|
|
1497
1609
|
!offset &&
|
|
1498
|
-
|
|
1610
|
+
!listAll &&
|
|
1611
|
+
// A widened search is a list with the miss said over it: as a page of
|
|
1612
|
+
// places, "helipad" matching nothing in China was never said at all.
|
|
1613
|
+
!widened &&
|
|
1499
1614
|
(!asked.city || (namedCountry && facets.cities.length >= CITY_PICKER_MIN));
|
|
1615
|
+
// "Today" where the hotels are - earliest and latest - for a page that has
|
|
1616
|
+
// no one hotel's clock to fill its date bar from (see the card's
|
|
1617
|
+
// `todayRange`): the UTC date was yesterday in Asia before 8am.
|
|
1618
|
+
const days = answer.hotels
|
|
1619
|
+
.map((h) => h.todayThere)
|
|
1620
|
+
.filter((x) => !!x)
|
|
1621
|
+
.sort();
|
|
1622
|
+
const today = days.length ? { min: days[0], max: days.at(-1) } : undefined;
|
|
1500
1623
|
if (choosing && facets) {
|
|
1501
1624
|
const countries = facets.countries.map((c) => ({
|
|
1502
1625
|
name: c.name,
|
|
@@ -1505,10 +1628,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1505
1628
|
.filter((x) => x.country === c.name)
|
|
1506
1629
|
.map((x) => ({ name: x.name, count: x.count })),
|
|
1507
1630
|
}));
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1631
|
+
// Every place, counted: a host with no card has only this sentence, and
|
|
1632
|
+
// "the most are in" three of forty cities left the other thirty-seven
|
|
1633
|
+
// for a guest to guess at.
|
|
1634
|
+
const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
|
|
1512
1635
|
const n = deps.vocab.nounPlural;
|
|
1513
1636
|
if (namedCountry) {
|
|
1514
1637
|
const [only] = countries;
|
|
@@ -1520,8 +1643,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1520
1643
|
scope: only,
|
|
1521
1644
|
ask: { city: asked.city, ...stayArgs },
|
|
1522
1645
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1646
|
+
...(today ? { today } : {}),
|
|
1523
1647
|
},
|
|
1524
|
-
text: `${total} ${deps.vocab.brand} ${n} in ${only?.name}, in ${only?.cities.length} cities
|
|
1648
|
+
text: `${total} ${deps.vocab.brand} ${n} in ${only?.name}, in ${only?.cities.length} cities to choose from: ${every(only?.cities ?? [])}. A city lists its ${n}; \`all\` lists every one in ${only?.name}.`,
|
|
1525
1649
|
};
|
|
1526
1650
|
}
|
|
1527
1651
|
const regions = [
|
|
@@ -1543,8 +1667,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1543
1667
|
regions,
|
|
1544
1668
|
ask: { ...stayArgs },
|
|
1545
1669
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1670
|
+
...(today ? { today } : {}),
|
|
1546
1671
|
},
|
|
1547
|
-
text: `${total} ${deps.vocab.brand} ${n} in ${facets.cities.length} cities across ${countries.length} countries
|
|
1672
|
+
text: `${total} ${deps.vocab.brand} ${n} in ${facets.cities.length} cities across ${countries.length} countries, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${every(r.countries)}`).join('; ')}. A city or a country lists its ${n}.`,
|
|
1548
1673
|
};
|
|
1549
1674
|
}
|
|
1550
1675
|
if (hotels.length === 0) {
|
|
@@ -1552,9 +1677,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1552
1677
|
...h,
|
|
1553
1678
|
summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
|
|
1554
1679
|
fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
|
|
1680
|
+
...(h.forStay
|
|
1681
|
+
? {
|
|
1682
|
+
forStay: {
|
|
1683
|
+
...h.forStay,
|
|
1684
|
+
total: formatMoney(h.forStay.total),
|
|
1685
|
+
nightly: formatMoney(h.forStay.nightly),
|
|
1686
|
+
},
|
|
1687
|
+
}
|
|
1688
|
+
: {}),
|
|
1555
1689
|
}));
|
|
1556
|
-
const
|
|
1690
|
+
const [first] = answer.nearest ?? [];
|
|
1691
|
+
// On named nights, a night of the stay that was priced - the list rate
|
|
1692
|
+
// was quoted beside a ceiling it was never held against.
|
|
1693
|
+
const cheapest = first?.forStay?.nightly ?? first?.fromNightly;
|
|
1557
1694
|
const capCurrency = answer.currencies[0] ?? cheapest?.currency ?? 'HKD';
|
|
1695
|
+
const cap = formatMoney(money(toMinor(Number(ceiling), capCurrency), capCurrency));
|
|
1696
|
+
const pastTheEnd = answer.total > 0 && offset > 0;
|
|
1697
|
+
const why = pastTheEnd
|
|
1698
|
+
? `That page is past the end: ${answer.total} match, so \`offset\` 0 to ${answer.total - 1} is where they are.`
|
|
1699
|
+
: filteredEmpty
|
|
1700
|
+
? emptiedBy(asked.filters ?? {}, answer.beforeFilters ?? 0, deps.vocab.nounPlural)
|
|
1701
|
+
: ceilingEmpty && nearest.length
|
|
1702
|
+
? `Nothing under ${cap} a night. The cheapest that matched is ${first?.name} at ${cheapest ? formatMoney(cheapest) : '-'}${answer.overBudget && answer.overBudget > 1 ? `, of ${answer.overBudget} over the ceiling` : ''}.`
|
|
1703
|
+
: ceilingEmpty
|
|
1704
|
+
? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
|
|
1705
|
+
: [
|
|
1706
|
+
`No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
|
|
1707
|
+
// What was tried and also failed is worth a sentence: it tells a
|
|
1708
|
+
// guest the word itself is the problem, not the narrowing.
|
|
1709
|
+
widened ? `${sentence(widened.to)} found nothing either.` : '',
|
|
1710
|
+
]
|
|
1711
|
+
.filter(Boolean)
|
|
1712
|
+
.join(' ');
|
|
1558
1713
|
return {
|
|
1559
1714
|
structured: {
|
|
1560
1715
|
view: 'no-hotels',
|
|
@@ -1563,33 +1718,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1563
1718
|
total: 0,
|
|
1564
1719
|
query,
|
|
1565
1720
|
...(nearest.length ? { nearest, overBudget: answer.overBudget } : {}),
|
|
1566
|
-
|
|
1567
|
-
|
|
1721
|
+
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1722
|
+
...(widened?.found ? { widened } : {}),
|
|
1723
|
+
// Why it is empty, in the words the model is told, so the card says
|
|
1724
|
+
// the same thing rather than guessing at it.
|
|
1725
|
+
why,
|
|
1726
|
+
// The way back out of a filter that emptied it: the same search -
|
|
1727
|
+
// its order, its ceiling and its nights too - with the chips to take
|
|
1728
|
+
// one off. Taking one off dropped the sort and the ceiling.
|
|
1568
1729
|
...(filteredEmpty
|
|
1569
1730
|
? {
|
|
1570
|
-
ask:
|
|
1571
|
-
...(asked.query ? { query: asked.query } : {}),
|
|
1572
|
-
...(asked.city ? { city: asked.city } : {}),
|
|
1573
|
-
...(asked.filters ?? {}),
|
|
1574
|
-
...stayArgs,
|
|
1575
|
-
},
|
|
1731
|
+
ask: echo(sort),
|
|
1576
1732
|
...(answer.refine ? { refine: refineFor(answer.refine, asked.filters, 0) } : {}),
|
|
1577
1733
|
beforeFilters: answer.beforeFilters,
|
|
1578
1734
|
}
|
|
1579
1735
|
: {}),
|
|
1580
1736
|
},
|
|
1581
|
-
text:
|
|
1582
|
-
?
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
// What was tried and also failed is worth a sentence: it tells a
|
|
1588
|
-
// guest the word itself is the problem, not the narrowing.
|
|
1589
|
-
widened ? `${sentence(widened.to)} found nothing either.` : '',
|
|
1590
|
-
]
|
|
1591
|
-
.filter(Boolean)
|
|
1592
|
-
.join(' '),
|
|
1737
|
+
text: [
|
|
1738
|
+
widened?.found ? `Nothing matched ${widened.from}, so this looked in ${widened.to}.` : '',
|
|
1739
|
+
why,
|
|
1740
|
+
]
|
|
1741
|
+
.filter(Boolean)
|
|
1742
|
+
.join(' '),
|
|
1593
1743
|
};
|
|
1594
1744
|
}
|
|
1595
1745
|
const forCard = hotels.map((h) => ({
|
|
@@ -1648,14 +1798,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1648
1798
|
sort: applied,
|
|
1649
1799
|
// The search as asked, so "show more" is this search's next page and
|
|
1650
1800
|
// not a new one - and a place chip keeps the order the guest chose.
|
|
1651
|
-
ask:
|
|
1652
|
-
...(asked.query ? { query: asked.query } : {}),
|
|
1653
|
-
...(asked.city ? { city: asked.city } : {}),
|
|
1654
|
-
...(applied !== 'group' ? { sort: applied } : {}),
|
|
1655
|
-
...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
|
|
1656
|
-
...(asked.filters ?? {}),
|
|
1657
|
-
...stayArgs,
|
|
1658
|
-
},
|
|
1801
|
+
ask: echo(applied),
|
|
1659
1802
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1660
1803
|
...(answer.refine
|
|
1661
1804
|
? { refine: refineFor(answer.refine, asked.filters, total, !!ask.stay) }
|
|
@@ -1693,7 +1836,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1693
1836
|
};
|
|
1694
1837
|
}
|
|
1695
1838
|
const h = detail.hotel;
|
|
1696
|
-
const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX);
|
|
1839
|
+
const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
|
|
1697
1840
|
const policies = await deps.backend.policies(h.id);
|
|
1698
1841
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
1699
1842
|
const base = {
|
|
@@ -1794,7 +1937,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1794
1937
|
},
|
|
1795
1938
|
text: [
|
|
1796
1939
|
`**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
|
|
1797
|
-
...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.
|
|
1940
|
+
...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${r.freeCancellationUntil.slice(0, 10)}` : r.refundable ? ', the free cancellation window has closed - cancelling costs a night' : ', non-refundable'}`),
|
|
1798
1941
|
flex.text,
|
|
1799
1942
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1800
1943
|
]
|
|
@@ -1808,14 +1951,35 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1808
1951
|
// transfer had nowhere to go. Asked with a booking, this lists that
|
|
1809
1952
|
// hotel's add-ons for that booking's nights, with what it already holds.
|
|
1810
1953
|
const code = str(args.booking);
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1954
|
+
const who = identity(deps, ctx, key, args);
|
|
1955
|
+
const { found, tooMany } = code
|
|
1956
|
+
? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
|
|
1957
|
+
: { found: null, tooMany: false };
|
|
1814
1958
|
const booked = found && (found.status === 'requested' || found.status === 'confirmed') ? found : undefined;
|
|
1959
|
+
if (code && found && !booked) {
|
|
1960
|
+
// Found, and over: saying "no booking matches" about a booking the guest
|
|
1961
|
+
// is looking at sends them hunting for a code they have right.
|
|
1962
|
+
return {
|
|
1963
|
+
structured: {
|
|
1964
|
+
view: 'error',
|
|
1965
|
+
...common(deps, key),
|
|
1966
|
+
error: 'booking-ended',
|
|
1967
|
+
detail: `${found.confirmationCode} is ${found.status}, so nothing can be added to it.`,
|
|
1968
|
+
},
|
|
1969
|
+
text: `${found.confirmationCode} is ${found.status}, so nothing can be added to it. An add-on goes on a booking that still holds its rooms.`,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1815
1972
|
if (code && !booked) {
|
|
1816
1973
|
return {
|
|
1817
|
-
structured: {
|
|
1818
|
-
|
|
1974
|
+
structured: {
|
|
1975
|
+
view: 'booking-not-found',
|
|
1976
|
+
...common(deps, key),
|
|
1977
|
+
query: code,
|
|
1978
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
1979
|
+
},
|
|
1980
|
+
text: tooMany
|
|
1981
|
+
? TOO_MANY_TRIES
|
|
1982
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
1819
1983
|
};
|
|
1820
1984
|
}
|
|
1821
1985
|
const wanted = booked ? booked.hotelId : str(args.hotel);
|
|
@@ -2017,8 +2181,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2017
2181
|
};
|
|
2018
2182
|
}
|
|
2019
2183
|
else {
|
|
2020
|
-
|
|
2184
|
+
// ADDED TO what is kept, not in place of it: a quiet room remembered
|
|
2185
|
+
// this time silently dropped the king bed remembered last time. A new
|
|
2186
|
+
// request does replace its opposite - twin beds over a king bed.
|
|
2187
|
+
const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
|
|
2188
|
+
const merged = [
|
|
2189
|
+
...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
|
|
2190
|
+
...lasting,
|
|
2191
|
+
];
|
|
2192
|
+
const kept = await deps.backend.rememberPreferences(signedIn, merged);
|
|
2021
2193
|
deps.members.set(key, { ...signedIn, preferences: kept });
|
|
2194
|
+
deps.members.setPreferences(signedIn.number, kept);
|
|
2022
2195
|
remembered = {
|
|
2023
2196
|
kept,
|
|
2024
2197
|
line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
|
|
@@ -2055,6 +2228,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2055
2228
|
quote: quoteForCard(booking.quote),
|
|
2056
2229
|
total: formatMoney(booking.quote.total),
|
|
2057
2230
|
...(asked.length ? { requested: asked } : {}),
|
|
2231
|
+
// Each its own warning colour - it rode on the first alone.
|
|
2232
|
+
...(booking.requests?.kinds.includes('accessible-room')
|
|
2233
|
+
? { accessibility: true }
|
|
2234
|
+
: {}),
|
|
2058
2235
|
})),
|
|
2059
2236
|
}
|
|
2060
2237
|
: {}),
|
|
@@ -2100,8 +2277,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2100
2277
|
const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
|
|
2101
2278
|
// Elsewhere, only with the code AND the email - see `listBookings`.
|
|
2102
2279
|
const code = str(args.booking);
|
|
2103
|
-
const there = code && str(args.email)
|
|
2280
|
+
const { found: there, tooMany } = code && str(args.email)
|
|
2281
|
+
? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
|
|
2282
|
+
: { found: null, tooMany: false };
|
|
2104
2283
|
const bookings = there && !here.some((b) => b.id === there.id) ? [there, ...here] : here;
|
|
2284
|
+
// ⚠️ A MISS IS SAID EVEN WHEN THERE IS SOMETHING TO LIST. With two
|
|
2285
|
+
// bookings in the conversation, a wrong code answered "2 bookings" and
|
|
2286
|
+
// nothing else - read as "found it" (2026-09-28 audit).
|
|
2287
|
+
const missed = code && !there;
|
|
2288
|
+
const missLine = tooMany
|
|
2289
|
+
? TOO_MANY_TRIES
|
|
2290
|
+
: code && !str(args.email)
|
|
2291
|
+
? 'A booking made elsewhere needs the email it was made with as well as its code.'
|
|
2292
|
+
: 'No booking matches that confirmation code and email. Both are needed, as they are on the confirmation.';
|
|
2105
2293
|
return {
|
|
2106
2294
|
structured: {
|
|
2107
2295
|
view: 'bookings',
|
|
@@ -2110,13 +2298,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2110
2298
|
count: bookings.length,
|
|
2111
2299
|
// What was looked for, so a card can say THAT did not match rather
|
|
2112
2300
|
// than "nothing in this conversation".
|
|
2113
|
-
...(
|
|
2301
|
+
...(missed ? { notFound: code, notFoundLine: missLine } : {}),
|
|
2302
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2114
2303
|
},
|
|
2115
|
-
text:
|
|
2116
|
-
?
|
|
2117
|
-
|
|
2118
|
-
?
|
|
2119
|
-
:
|
|
2304
|
+
text: [
|
|
2305
|
+
missed ? missLine : '',
|
|
2306
|
+
bookings.length
|
|
2307
|
+
? bookings.map(bookingLine).join('\n')
|
|
2308
|
+
: code
|
|
2309
|
+
? ''
|
|
2310
|
+
: 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
|
|
2311
|
+
]
|
|
2312
|
+
.filter(Boolean)
|
|
2313
|
+
.join('\n'),
|
|
2120
2314
|
};
|
|
2121
2315
|
}
|
|
2122
2316
|
if (name === t.previewChange || name === t.changeBooking) {
|
|
@@ -2130,11 +2324,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2130
2324
|
...(str(args.extra) ? { extra: str(args.extra) } : {}),
|
|
2131
2325
|
...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
|
|
2132
2326
|
};
|
|
2133
|
-
const preview = await deps.backend.previewChange(ref, change, who);
|
|
2327
|
+
const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewChange(ref, change, who));
|
|
2134
2328
|
if (!preview) {
|
|
2135
2329
|
return {
|
|
2136
|
-
structured: {
|
|
2137
|
-
|
|
2330
|
+
structured: {
|
|
2331
|
+
view: 'booking-not-found',
|
|
2332
|
+
...common(deps, key),
|
|
2333
|
+
query: ref,
|
|
2334
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2335
|
+
},
|
|
2336
|
+
text: tooMany
|
|
2337
|
+
? TOO_MANY_TRIES
|
|
2338
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
2138
2339
|
};
|
|
2139
2340
|
}
|
|
2140
2341
|
const stayOf = (x) => ({
|
|
@@ -2176,16 +2377,48 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2176
2377
|
: 'The total is unchanged.',
|
|
2177
2378
|
preview.rule,
|
|
2178
2379
|
`Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
|
|
2179
|
-
|
|
2380
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2381
|
+
]
|
|
2382
|
+
.filter(Boolean)
|
|
2383
|
+
.join(' ')
|
|
2180
2384
|
: preview.rule,
|
|
2181
2385
|
};
|
|
2182
2386
|
// ⚠️ THE PREVIEW IS NOT ADVISORY, for the same reason the cancellation's is
|
|
2183
2387
|
// not: a change re-prices the stay, and a guest who never saw the new total
|
|
2184
|
-
// finds it at the desk.
|
|
2388
|
+
// finds it at the desk. And it confirms what was SHOWN - see
|
|
2389
|
+
// `shownPreviews`.
|
|
2390
|
+
// Keyed by what the change COMES TO, not how it was asked: the card sends
|
|
2391
|
+
// its confirmation from the preview's own nights and rooms, which need not
|
|
2392
|
+
// be the words the preview was asked in.
|
|
2393
|
+
const shownKey = [
|
|
2394
|
+
key,
|
|
2395
|
+
'change',
|
|
2396
|
+
preview.bookingId,
|
|
2397
|
+
preview.to?.stay.checkIn,
|
|
2398
|
+
preview.to?.stay.checkOut,
|
|
2399
|
+
preview.to?.rooms,
|
|
2400
|
+
preview.to?.roomName,
|
|
2401
|
+
preview.addOn ? `${preview.addOn.id}:${preview.addOn.to}` : '',
|
|
2402
|
+
].join('|');
|
|
2403
|
+
const wasShown = previewsOf(deps).get(shownKey);
|
|
2404
|
+
const newTotal = preview.to?.total.minor;
|
|
2405
|
+
if (newTotal !== undefined)
|
|
2406
|
+
remember(deps, shownKey, newTotal);
|
|
2185
2407
|
if (name === t.previewChange || args.confirmed !== true)
|
|
2186
2408
|
return asPreview;
|
|
2187
2409
|
if (!preview.changeable)
|
|
2188
2410
|
return asPreview;
|
|
2411
|
+
if (wasShown === undefined || wasShown !== newTotal) {
|
|
2412
|
+
return {
|
|
2413
|
+
...asPreview,
|
|
2414
|
+
text: [
|
|
2415
|
+
wasShown === undefined
|
|
2416
|
+
? 'Nothing has changed yet: this is what the change would do, for the guest to see first.'
|
|
2417
|
+
: `The price of this change has moved since it was shown - it is now ${preview.to ? formatMoney(preview.to.total) : '-'} in all. Nothing has changed yet.`,
|
|
2418
|
+
asPreview.text,
|
|
2419
|
+
].join(' '),
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2189
2422
|
try {
|
|
2190
2423
|
const changed = await deps.backend.changeBooking(ref, change, who);
|
|
2191
2424
|
if (!changed)
|
|
@@ -2219,11 +2452,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2219
2452
|
if (name === t.previewCancellation || name === t.cancelBooking) {
|
|
2220
2453
|
const ref = str(args.booking);
|
|
2221
2454
|
const who = identity(deps, ctx, key, args);
|
|
2222
|
-
const preview = await deps.backend.previewCancellation(ref, who);
|
|
2455
|
+
const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewCancellation(ref, who));
|
|
2223
2456
|
if (!preview) {
|
|
2224
2457
|
return {
|
|
2225
|
-
structured: {
|
|
2226
|
-
|
|
2458
|
+
structured: {
|
|
2459
|
+
view: 'booking-not-found',
|
|
2460
|
+
...common(deps, key),
|
|
2461
|
+
query: ref,
|
|
2462
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2463
|
+
},
|
|
2464
|
+
text: tooMany
|
|
2465
|
+
? TOO_MANY_TRIES
|
|
2466
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
2227
2467
|
};
|
|
2228
2468
|
}
|
|
2229
2469
|
const asPreview = {
|
|
@@ -2244,17 +2484,33 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2244
2484
|
preview.cancellable
|
|
2245
2485
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
2246
2486
|
: '',
|
|
2487
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2247
2488
|
]
|
|
2248
2489
|
.filter(Boolean)
|
|
2249
2490
|
.join(' '),
|
|
2250
2491
|
};
|
|
2251
2492
|
// ⚠️ THE PREVIEW IS NOT ADVISORY. Cancelling without one is how a guest
|
|
2252
2493
|
// loses a night's rate they were never told about, so the cancel tool
|
|
2253
|
-
// returns this same preview and does nothing until it is told again
|
|
2494
|
+
// returns this same preview and does nothing until it is told again - and
|
|
2495
|
+
// told again about the fee that was SHOWN (`shownPreviews`).
|
|
2496
|
+
const shownKey = `${key}|cancel|${preview.bookingId}`;
|
|
2497
|
+
const wasShown = previewsOf(deps).get(shownKey);
|
|
2498
|
+
remember(deps, shownKey, preview.fee.minor);
|
|
2254
2499
|
if (name === t.previewCancellation || args.confirmed !== true)
|
|
2255
2500
|
return asPreview;
|
|
2256
2501
|
if (!preview.cancellable)
|
|
2257
2502
|
return asPreview;
|
|
2503
|
+
if (wasShown === undefined || wasShown !== preview.fee.minor) {
|
|
2504
|
+
return {
|
|
2505
|
+
...asPreview,
|
|
2506
|
+
text: [
|
|
2507
|
+
wasShown === undefined
|
|
2508
|
+
? 'Nothing is cancelled yet: this is what cancelling would cost, for the guest to see first.'
|
|
2509
|
+
: `The fee has changed since it was shown - it is now ${formatMoney(preview.fee)}. Nothing is cancelled yet.`,
|
|
2510
|
+
asPreview.text,
|
|
2511
|
+
].join(' '),
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2258
2514
|
const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
|
|
2259
2515
|
if (!cancelled)
|
|
2260
2516
|
return asPreview;
|
|
@@ -2268,7 +2524,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2268
2524
|
rule: preview.rule,
|
|
2269
2525
|
cancellable: false,
|
|
2270
2526
|
},
|
|
2271
|
-
text:
|
|
2527
|
+
text: [
|
|
2528
|
+
`${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
|
|
2529
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2530
|
+
]
|
|
2531
|
+
.filter(Boolean)
|
|
2532
|
+
.join(' '),
|
|
2272
2533
|
};
|
|
2273
2534
|
}
|
|
2274
2535
|
if (name === t.compare) {
|
|
@@ -2282,6 +2543,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2282
2543
|
stay: asStay(args),
|
|
2283
2544
|
recommended: str(args.recommended) || undefined,
|
|
2284
2545
|
title: str(args.title) || undefined,
|
|
2546
|
+
// The member's rate, like the list and the hotel beside it: without
|
|
2547
|
+
// it the comparison quoted HK$5,424 where both said HK$4,610.
|
|
2548
|
+
who: identity(deps, ctx, key),
|
|
2285
2549
|
});
|
|
2286
2550
|
if (comparison.columns.length === 0) {
|
|
2287
2551
|
return {
|
|
@@ -2357,7 +2621,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2357
2621
|
if (name === t.signIn) {
|
|
2358
2622
|
const number = str(args.number);
|
|
2359
2623
|
const surname = str(args.surname);
|
|
2624
|
+
// Counted like a booking lookup - see limits.ts. A number and a surname
|
|
2625
|
+
// read a tier, a point balance and saved preferences.
|
|
2626
|
+
const tries = [`signin|${key}`, `member|${number.replace(/\s+/g, '').toUpperCase()}`];
|
|
2627
|
+
if (deps.misses?.blocked(tries)) {
|
|
2628
|
+
return {
|
|
2629
|
+
structured: {
|
|
2630
|
+
view: 'member-not-found',
|
|
2631
|
+
...common(deps, key),
|
|
2632
|
+
query: number,
|
|
2633
|
+
tooMany: true,
|
|
2634
|
+
},
|
|
2635
|
+
text: 'Too many membership numbers and surnames that did not match, so signing in is paused for a quarter of an hour. Check both as they are printed on the card, then try again.',
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2360
2638
|
const member = await deps.backend.signInMember(number, surname);
|
|
2639
|
+
if (!member)
|
|
2640
|
+
deps.misses?.miss(tries);
|
|
2361
2641
|
if (!member) {
|
|
2362
2642
|
// ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
|
|
2363
2643
|
// enough, have the discount" - is inventing a membership, and the guest
|
|
@@ -2403,6 +2683,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2403
2683
|
await deps.backend.rememberPreferences(m, []);
|
|
2404
2684
|
const { preferences: _gone, ...member } = m;
|
|
2405
2685
|
deps.members.set(key, member);
|
|
2686
|
+
deps.members.setPreferences(m.number, []);
|
|
2406
2687
|
return {
|
|
2407
2688
|
structured: { view: 'member', ...common(deps, key), member },
|
|
2408
2689
|
text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
|