@curless/hotel-storefront 0.18.3 → 0.19.1
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__/present.test.js +13 -0
- package/dist/__tests__/present.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 +298 -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 +319 -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 +10 -6
- 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 +391 -96
- 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.
|
|
@@ -1205,6 +1279,7 @@ const currentTrip = async (deps, key, who) => {
|
|
|
1205
1279
|
};
|
|
1206
1280
|
}
|
|
1207
1281
|
const shaped = parts.map(quoteForCard);
|
|
1282
|
+
const bookable = parts.map((p) => p.lines.some((l) => l.kind === 'room'));
|
|
1208
1283
|
return {
|
|
1209
1284
|
structured: {
|
|
1210
1285
|
view: 'trip',
|
|
@@ -1213,9 +1288,24 @@ const currentTrip = async (deps, key, who) => {
|
|
|
1213
1288
|
// One block per currency, each with its own taxes and total. `total` is
|
|
1214
1289
|
// the totals side by side for anything that reads one string - a list,
|
|
1215
1290
|
// never a sum.
|
|
1216
|
-
|
|
1291
|
+
//
|
|
1292
|
+
// ⚠️ A CURRENCY OF ADD-ONS ALONE IS NOT PART OF WHAT IS BOOKED. A
|
|
1293
|
+
// transfer at a Paris hotel with no Paris room made its own € block, and
|
|
1294
|
+
// the trip's total read "HK$8,588 · €347" for a request that refuses
|
|
1295
|
+
// the €347 (2026-09-28 audit). It is drawn, marked, and left out.
|
|
1296
|
+
groups: shaped.map((p, i) => ({
|
|
1297
|
+
...p,
|
|
1298
|
+
currency: parts[i]?.total.currency,
|
|
1299
|
+
...(bookable[i] ? {} : { goesWithAStay: true }),
|
|
1300
|
+
})),
|
|
1217
1301
|
currencies: parts.map((p) => p.total.currency),
|
|
1218
|
-
|
|
1302
|
+
// Side by side with a dot, not a plus: "HK$8,588 + ¥152,550" reads as
|
|
1303
|
+
// a sum nobody can do.
|
|
1304
|
+
total: (shaped.filter((_, i) => bookable[i]).length
|
|
1305
|
+
? shaped.filter((_, i) => bookable[i])
|
|
1306
|
+
: shaped)
|
|
1307
|
+
.map((p) => p.total)
|
|
1308
|
+
.join(' · '),
|
|
1219
1309
|
...needs,
|
|
1220
1310
|
...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
|
|
1221
1311
|
count: lines.length,
|
|
@@ -1317,7 +1407,9 @@ const suggestionsFor = async (deps, d) => {
|
|
|
1317
1407
|
const nearby = await deps.backend.searchHotels({ city: hotel.city, limit: 2 });
|
|
1318
1408
|
if (nearby.total > 1)
|
|
1319
1409
|
out.push({
|
|
1320
|
-
label:
|
|
1410
|
+
// The label IS what is sent: a chip that sends more than it says is
|
|
1411
|
+
// a chip that says something else in the guest's name.
|
|
1412
|
+
label: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
|
|
1321
1413
|
say: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
|
|
1322
1414
|
});
|
|
1323
1415
|
}
|
|
@@ -1338,7 +1430,7 @@ const suggestionsFor = async (deps, d) => {
|
|
|
1338
1430
|
}
|
|
1339
1431
|
if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
|
|
1340
1432
|
out.push({
|
|
1341
|
-
label: 'Put
|
|
1433
|
+
label: 'Put the stays on this plan in the trip',
|
|
1342
1434
|
say: 'Put the stays on this plan in the trip',
|
|
1343
1435
|
});
|
|
1344
1436
|
if (view === 'booking') {
|
|
@@ -1395,11 +1487,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1395
1487
|
sort,
|
|
1396
1488
|
maxNightly: ceiling,
|
|
1397
1489
|
stay: asStay(args),
|
|
1398
|
-
|
|
1490
|
+
// Clamped here, so what is said and echoed is what was priced: "9 rooms"
|
|
1491
|
+
// was said over prices for five.
|
|
1492
|
+
rooms: typeof args.rooms === 'number' && Number.isFinite(args.rooms)
|
|
1493
|
+
? Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(args.rooms)))
|
|
1494
|
+
: undefined,
|
|
1399
1495
|
filters: filtersOf(args),
|
|
1400
1496
|
};
|
|
1401
1497
|
const who = identity(deps, ctx, key, args);
|
|
1402
1498
|
let answer = await deps.backend.searchHotels(ask, who);
|
|
1499
|
+
// A brand as the data spells it, so its chip shows as ON and can be turned
|
|
1500
|
+
// off: `brand: 'kerry'` filtered, and then no chip matched it.
|
|
1501
|
+
const brandAsked = ask.filters?.brand?.toLowerCase();
|
|
1502
|
+
const brandNamed = answer.refine?.brands.find((b) => b.name.toLowerCase() === brandAsked)?.name;
|
|
1503
|
+
if (ask.filters && brandNamed)
|
|
1504
|
+
ask.filters = { ...ask.filters, brand: brandNamed };
|
|
1403
1505
|
// The search the answer actually came from - the widened one, when it was
|
|
1404
1506
|
// widened - so its next page is the next page of THAT.
|
|
1405
1507
|
let asked = ask;
|
|
@@ -1419,20 +1521,29 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1419
1521
|
// comment was written to forbid, committed by the code under it.
|
|
1420
1522
|
// `overBudget` is how the seam says the place and the words did match.
|
|
1421
1523
|
let widened;
|
|
1422
|
-
// ⚠️ A FILTER THAT EMPTIED THE LIST IS NOT A SEARCH THAT
|
|
1423
|
-
// for the same reason as the ceiling
|
|
1424
|
-
|
|
1425
|
-
|
|
1524
|
+
// ⚠️ A FILTER OR A CEILING THAT EMPTIED THE LIST IS NOT A SEARCH THAT
|
|
1525
|
+
// FAILED either, for the same reason as the ceiling above: the place
|
|
1526
|
+
// matched. What matched the place and the words, before either:
|
|
1527
|
+
const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
|
|
1528
|
+
if (placedCount(answer) === 0) {
|
|
1426
1529
|
const wider = widen(ask);
|
|
1427
1530
|
if (wider) {
|
|
1428
1531
|
const second = await deps.backend.searchHotels(wider.ask, who);
|
|
1429
|
-
|
|
1430
|
-
|
|
1532
|
+
// Found when the wider PLACE matched - a wider search emptied by the
|
|
1533
|
+
// same ceiling or filter is still the place, and "Hong Kong found
|
|
1534
|
+
// nothing either" was said of four hotels over the ceiling.
|
|
1535
|
+
widened = { from: wider.from, to: wider.to, found: placedCount(second) > 0 };
|
|
1536
|
+
if (widened.found) {
|
|
1431
1537
|
answer = second;
|
|
1432
1538
|
asked = { ...ask, ...wider.ask };
|
|
1433
1539
|
}
|
|
1434
1540
|
}
|
|
1435
1541
|
}
|
|
1542
|
+
const filteredEmpty = !!asked.filters &&
|
|
1543
|
+
answer.total === 0 &&
|
|
1544
|
+
placedCount(answer) > 0 &&
|
|
1545
|
+
(answer.beforeCeiling ?? 0) === 0;
|
|
1546
|
+
const ceilingEmpty = answer.total === 0 && (answer.beforeCeiling ?? 0) > 0;
|
|
1436
1547
|
// The ceiling again on this side of the seam. MAX_PAGE is part of the
|
|
1437
1548
|
// backend contract, but "the next backend honours it" is not something the
|
|
1438
1549
|
// card can be built on - and the cost of it being wrong is the whole group
|
|
@@ -1486,6 +1597,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1486
1597
|
...(ask.rooms && ask.rooms > 1 ? { rooms: ask.rooms } : {}),
|
|
1487
1598
|
}
|
|
1488
1599
|
: {};
|
|
1600
|
+
// ⚠️ THE SEARCH AS ASKED, `all` INCLUDED. The echo dropped `all`, so every
|
|
1601
|
+
// re-query the card made from a "Browse all" list - new dates, the default
|
|
1602
|
+
// order, clearing a filter - met no place, no words and no filter, and
|
|
1603
|
+
// came back as the destinations page (2026-09-28 audit). Built once, for
|
|
1604
|
+
// the list and for the list a filter or a ceiling emptied.
|
|
1605
|
+
const listAll = args.all === true;
|
|
1606
|
+
const echo = (applied) => ({
|
|
1607
|
+
...(asked.query ? { query: asked.query } : {}),
|
|
1608
|
+
...(asked.city ? { city: asked.city } : {}),
|
|
1609
|
+
...(applied !== 'group' ? { sort: applied } : {}),
|
|
1610
|
+
...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
|
|
1611
|
+
...(asked.filters ?? {}),
|
|
1612
|
+
...(listAll ? { all: true } : {}),
|
|
1613
|
+
...stayArgs,
|
|
1614
|
+
});
|
|
1489
1615
|
const namedCountry = !!asked.city &&
|
|
1490
1616
|
facets?.countries.length === 1 &&
|
|
1491
1617
|
facets.countries[0]?.name.toLowerCase() === asked.city.toLowerCase();
|
|
@@ -1495,8 +1621,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1495
1621
|
sort === 'group' &&
|
|
1496
1622
|
!asked.maxNightly &&
|
|
1497
1623
|
!offset &&
|
|
1498
|
-
|
|
1624
|
+
!listAll &&
|
|
1625
|
+
// A widened search is a list with the miss said over it: as a page of
|
|
1626
|
+
// places, "helipad" matching nothing in China was never said at all.
|
|
1627
|
+
!widened &&
|
|
1499
1628
|
(!asked.city || (namedCountry && facets.cities.length >= CITY_PICKER_MIN));
|
|
1629
|
+
// "Today" where the hotels are - earliest and latest - for a page that has
|
|
1630
|
+
// no one hotel's clock to fill its date bar from (see the card's
|
|
1631
|
+
// `todayRange`): the UTC date was yesterday in Asia before 8am.
|
|
1632
|
+
const days = answer.hotels
|
|
1633
|
+
.map((h) => h.todayThere)
|
|
1634
|
+
.filter((x) => !!x)
|
|
1635
|
+
.sort();
|
|
1636
|
+
const today = days.length ? { min: days[0], max: days.at(-1) } : undefined;
|
|
1500
1637
|
if (choosing && facets) {
|
|
1501
1638
|
const countries = facets.countries.map((c) => ({
|
|
1502
1639
|
name: c.name,
|
|
@@ -1505,10 +1642,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1505
1642
|
.filter((x) => x.country === c.name)
|
|
1506
1643
|
.map((x) => ({ name: x.name, count: x.count })),
|
|
1507
1644
|
}));
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1645
|
+
// Every place, counted: a host with no card has only this sentence, and
|
|
1646
|
+
// "the most are in" three of forty cities left the other thirty-seven
|
|
1647
|
+
// for a guest to guess at.
|
|
1648
|
+
const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
|
|
1512
1649
|
const n = deps.vocab.nounPlural;
|
|
1513
1650
|
if (namedCountry) {
|
|
1514
1651
|
const [only] = countries;
|
|
@@ -1520,8 +1657,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1520
1657
|
scope: only,
|
|
1521
1658
|
ask: { city: asked.city, ...stayArgs },
|
|
1522
1659
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1660
|
+
...(today ? { today } : {}),
|
|
1523
1661
|
},
|
|
1524
|
-
text: `${total} ${deps.vocab.brand} ${n} in ${only?.name}, in ${only?.cities.length} cities
|
|
1662
|
+
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
1663
|
};
|
|
1526
1664
|
}
|
|
1527
1665
|
const regions = [
|
|
@@ -1543,8 +1681,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1543
1681
|
regions,
|
|
1544
1682
|
ask: { ...stayArgs },
|
|
1545
1683
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1684
|
+
...(today ? { today } : {}),
|
|
1546
1685
|
},
|
|
1547
|
-
text: `${total} ${deps.vocab.brand} ${n} in ${facets.cities.length} cities across ${countries.length} countries
|
|
1686
|
+
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
1687
|
};
|
|
1549
1688
|
}
|
|
1550
1689
|
if (hotels.length === 0) {
|
|
@@ -1552,9 +1691,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1552
1691
|
...h,
|
|
1553
1692
|
summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
|
|
1554
1693
|
fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
|
|
1694
|
+
...(h.forStay
|
|
1695
|
+
? {
|
|
1696
|
+
forStay: {
|
|
1697
|
+
...h.forStay,
|
|
1698
|
+
total: formatMoney(h.forStay.total),
|
|
1699
|
+
nightly: formatMoney(h.forStay.nightly),
|
|
1700
|
+
},
|
|
1701
|
+
}
|
|
1702
|
+
: {}),
|
|
1555
1703
|
}));
|
|
1556
|
-
const
|
|
1704
|
+
const [first] = answer.nearest ?? [];
|
|
1705
|
+
// On named nights, a night of the stay that was priced - the list rate
|
|
1706
|
+
// was quoted beside a ceiling it was never held against.
|
|
1707
|
+
const cheapest = first?.forStay?.nightly ?? first?.fromNightly;
|
|
1557
1708
|
const capCurrency = answer.currencies[0] ?? cheapest?.currency ?? 'HKD';
|
|
1709
|
+
const cap = formatMoney(money(toMinor(Number(ceiling), capCurrency), capCurrency));
|
|
1710
|
+
const pastTheEnd = answer.total > 0 && offset > 0;
|
|
1711
|
+
const why = pastTheEnd
|
|
1712
|
+
? `That page is past the end: ${answer.total} match, so \`offset\` 0 to ${answer.total - 1} is where they are.`
|
|
1713
|
+
: filteredEmpty
|
|
1714
|
+
? emptiedBy(asked.filters ?? {}, answer.beforeFilters ?? 0, deps.vocab.nounPlural)
|
|
1715
|
+
: ceilingEmpty && nearest.length
|
|
1716
|
+
? `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` : ''}.`
|
|
1717
|
+
: ceilingEmpty
|
|
1718
|
+
? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
|
|
1719
|
+
: [
|
|
1720
|
+
`No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
|
|
1721
|
+
// What was tried and also failed is worth a sentence: it tells a
|
|
1722
|
+
// guest the word itself is the problem, not the narrowing.
|
|
1723
|
+
widened ? `${sentence(widened.to)} found nothing either.` : '',
|
|
1724
|
+
]
|
|
1725
|
+
.filter(Boolean)
|
|
1726
|
+
.join(' ');
|
|
1558
1727
|
return {
|
|
1559
1728
|
structured: {
|
|
1560
1729
|
view: 'no-hotels',
|
|
@@ -1563,33 +1732,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1563
1732
|
total: 0,
|
|
1564
1733
|
query,
|
|
1565
1734
|
...(nearest.length ? { nearest, overBudget: answer.overBudget } : {}),
|
|
1566
|
-
|
|
1567
|
-
|
|
1735
|
+
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1736
|
+
...(widened?.found ? { widened } : {}),
|
|
1737
|
+
// Why it is empty, in the words the model is told, so the card says
|
|
1738
|
+
// the same thing rather than guessing at it.
|
|
1739
|
+
why,
|
|
1740
|
+
// The way back out of a filter that emptied it: the same search -
|
|
1741
|
+
// its order, its ceiling and its nights too - with the chips to take
|
|
1742
|
+
// one off. Taking one off dropped the sort and the ceiling.
|
|
1568
1743
|
...(filteredEmpty
|
|
1569
1744
|
? {
|
|
1570
|
-
ask:
|
|
1571
|
-
...(asked.query ? { query: asked.query } : {}),
|
|
1572
|
-
...(asked.city ? { city: asked.city } : {}),
|
|
1573
|
-
...(asked.filters ?? {}),
|
|
1574
|
-
...stayArgs,
|
|
1575
|
-
},
|
|
1745
|
+
ask: echo(sort),
|
|
1576
1746
|
...(answer.refine ? { refine: refineFor(answer.refine, asked.filters, 0) } : {}),
|
|
1577
1747
|
beforeFilters: answer.beforeFilters,
|
|
1578
1748
|
}
|
|
1579
1749
|
: {}),
|
|
1580
1750
|
},
|
|
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(' '),
|
|
1751
|
+
text: [
|
|
1752
|
+
widened?.found ? `Nothing matched ${widened.from}, so this looked in ${widened.to}.` : '',
|
|
1753
|
+
why,
|
|
1754
|
+
]
|
|
1755
|
+
.filter(Boolean)
|
|
1756
|
+
.join(' '),
|
|
1593
1757
|
};
|
|
1594
1758
|
}
|
|
1595
1759
|
const forCard = hotels.map((h) => ({
|
|
@@ -1648,14 +1812,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1648
1812
|
sort: applied,
|
|
1649
1813
|
// The search as asked, so "show more" is this search's next page and
|
|
1650
1814
|
// 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
|
-
},
|
|
1815
|
+
ask: echo(applied),
|
|
1659
1816
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
1660
1817
|
...(answer.refine
|
|
1661
1818
|
? { refine: refineFor(answer.refine, asked.filters, total, !!ask.stay) }
|
|
@@ -1693,7 +1850,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1693
1850
|
};
|
|
1694
1851
|
}
|
|
1695
1852
|
const h = detail.hotel;
|
|
1696
|
-
const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX);
|
|
1853
|
+
const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
|
|
1697
1854
|
const policies = await deps.backend.policies(h.id);
|
|
1698
1855
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
1699
1856
|
const base = {
|
|
@@ -1794,7 +1951,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1794
1951
|
},
|
|
1795
1952
|
text: [
|
|
1796
1953
|
`**${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.
|
|
1954
|
+
...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
1955
|
flex.text,
|
|
1799
1956
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1800
1957
|
]
|
|
@@ -1808,14 +1965,35 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1808
1965
|
// transfer had nowhere to go. Asked with a booking, this lists that
|
|
1809
1966
|
// hotel's add-ons for that booking's nights, with what it already holds.
|
|
1810
1967
|
const code = str(args.booking);
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1968
|
+
const who = identity(deps, ctx, key, args);
|
|
1969
|
+
const { found, tooMany } = code
|
|
1970
|
+
? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
|
|
1971
|
+
: { found: null, tooMany: false };
|
|
1814
1972
|
const booked = found && (found.status === 'requested' || found.status === 'confirmed') ? found : undefined;
|
|
1973
|
+
if (code && found && !booked) {
|
|
1974
|
+
// Found, and over: saying "no booking matches" about a booking the guest
|
|
1975
|
+
// is looking at sends them hunting for a code they have right.
|
|
1976
|
+
return {
|
|
1977
|
+
structured: {
|
|
1978
|
+
view: 'error',
|
|
1979
|
+
...common(deps, key),
|
|
1980
|
+
error: 'booking-ended',
|
|
1981
|
+
detail: `${found.confirmationCode} is ${found.status}, so nothing can be added to it.`,
|
|
1982
|
+
},
|
|
1983
|
+
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.`,
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1815
1986
|
if (code && !booked) {
|
|
1816
1987
|
return {
|
|
1817
|
-
structured: {
|
|
1818
|
-
|
|
1988
|
+
structured: {
|
|
1989
|
+
view: 'booking-not-found',
|
|
1990
|
+
...common(deps, key),
|
|
1991
|
+
query: code,
|
|
1992
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
1993
|
+
},
|
|
1994
|
+
text: tooMany
|
|
1995
|
+
? TOO_MANY_TRIES
|
|
1996
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
1819
1997
|
};
|
|
1820
1998
|
}
|
|
1821
1999
|
const wanted = booked ? booked.hotelId : str(args.hotel);
|
|
@@ -2017,8 +2195,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2017
2195
|
};
|
|
2018
2196
|
}
|
|
2019
2197
|
else {
|
|
2020
|
-
|
|
2198
|
+
// ADDED TO what is kept, not in place of it: a quiet room remembered
|
|
2199
|
+
// this time silently dropped the king bed remembered last time. A new
|
|
2200
|
+
// request does replace its opposite - twin beds over a king bed.
|
|
2201
|
+
const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
|
|
2202
|
+
const merged = [
|
|
2203
|
+
...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
|
|
2204
|
+
...lasting,
|
|
2205
|
+
];
|
|
2206
|
+
const kept = await deps.backend.rememberPreferences(signedIn, merged);
|
|
2021
2207
|
deps.members.set(key, { ...signedIn, preferences: kept });
|
|
2208
|
+
deps.members.setPreferences(signedIn.number, kept);
|
|
2022
2209
|
remembered = {
|
|
2023
2210
|
kept,
|
|
2024
2211
|
line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
|
|
@@ -2055,6 +2242,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2055
2242
|
quote: quoteForCard(booking.quote),
|
|
2056
2243
|
total: formatMoney(booking.quote.total),
|
|
2057
2244
|
...(asked.length ? { requested: asked } : {}),
|
|
2245
|
+
// Each its own warning colour - it rode on the first alone.
|
|
2246
|
+
...(booking.requests?.kinds.includes('accessible-room')
|
|
2247
|
+
? { accessibility: true }
|
|
2248
|
+
: {}),
|
|
2058
2249
|
})),
|
|
2059
2250
|
}
|
|
2060
2251
|
: {}),
|
|
@@ -2100,8 +2291,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2100
2291
|
const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
|
|
2101
2292
|
// Elsewhere, only with the code AND the email - see `listBookings`.
|
|
2102
2293
|
const code = str(args.booking);
|
|
2103
|
-
const there = code && str(args.email)
|
|
2294
|
+
const { found: there, tooMany } = code && str(args.email)
|
|
2295
|
+
? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
|
|
2296
|
+
: { found: null, tooMany: false };
|
|
2104
2297
|
const bookings = there && !here.some((b) => b.id === there.id) ? [there, ...here] : here;
|
|
2298
|
+
// ⚠️ A MISS IS SAID EVEN WHEN THERE IS SOMETHING TO LIST. With two
|
|
2299
|
+
// bookings in the conversation, a wrong code answered "2 bookings" and
|
|
2300
|
+
// nothing else - read as "found it" (2026-09-28 audit).
|
|
2301
|
+
const missed = code && !there;
|
|
2302
|
+
const missLine = tooMany
|
|
2303
|
+
? TOO_MANY_TRIES
|
|
2304
|
+
: code && !str(args.email)
|
|
2305
|
+
? 'A booking made elsewhere needs the email it was made with as well as its code.'
|
|
2306
|
+
: 'No booking matches that confirmation code and email. Both are needed, as they are on the confirmation.';
|
|
2105
2307
|
return {
|
|
2106
2308
|
structured: {
|
|
2107
2309
|
view: 'bookings',
|
|
@@ -2110,13 +2312,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2110
2312
|
count: bookings.length,
|
|
2111
2313
|
// What was looked for, so a card can say THAT did not match rather
|
|
2112
2314
|
// than "nothing in this conversation".
|
|
2113
|
-
...(
|
|
2315
|
+
...(missed ? { notFound: code, notFoundLine: missLine } : {}),
|
|
2316
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2114
2317
|
},
|
|
2115
|
-
text:
|
|
2116
|
-
?
|
|
2117
|
-
|
|
2118
|
-
?
|
|
2119
|
-
:
|
|
2318
|
+
text: [
|
|
2319
|
+
missed ? missLine : '',
|
|
2320
|
+
bookings.length
|
|
2321
|
+
? bookings.map(bookingLine).join('\n')
|
|
2322
|
+
: code
|
|
2323
|
+
? ''
|
|
2324
|
+
: 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
|
|
2325
|
+
]
|
|
2326
|
+
.filter(Boolean)
|
|
2327
|
+
.join('\n'),
|
|
2120
2328
|
};
|
|
2121
2329
|
}
|
|
2122
2330
|
if (name === t.previewChange || name === t.changeBooking) {
|
|
@@ -2130,11 +2338,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2130
2338
|
...(str(args.extra) ? { extra: str(args.extra) } : {}),
|
|
2131
2339
|
...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
|
|
2132
2340
|
};
|
|
2133
|
-
const preview = await deps.backend.previewChange(ref, change, who);
|
|
2341
|
+
const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewChange(ref, change, who));
|
|
2134
2342
|
if (!preview) {
|
|
2135
2343
|
return {
|
|
2136
|
-
structured: {
|
|
2137
|
-
|
|
2344
|
+
structured: {
|
|
2345
|
+
view: 'booking-not-found',
|
|
2346
|
+
...common(deps, key),
|
|
2347
|
+
query: ref,
|
|
2348
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2349
|
+
},
|
|
2350
|
+
text: tooMany
|
|
2351
|
+
? TOO_MANY_TRIES
|
|
2352
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
2138
2353
|
};
|
|
2139
2354
|
}
|
|
2140
2355
|
const stayOf = (x) => ({
|
|
@@ -2176,16 +2391,48 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2176
2391
|
: 'The total is unchanged.',
|
|
2177
2392
|
preview.rule,
|
|
2178
2393
|
`Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
|
|
2179
|
-
|
|
2394
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2395
|
+
]
|
|
2396
|
+
.filter(Boolean)
|
|
2397
|
+
.join(' ')
|
|
2180
2398
|
: preview.rule,
|
|
2181
2399
|
};
|
|
2182
2400
|
// ⚠️ THE PREVIEW IS NOT ADVISORY, for the same reason the cancellation's is
|
|
2183
2401
|
// not: a change re-prices the stay, and a guest who never saw the new total
|
|
2184
|
-
// finds it at the desk.
|
|
2402
|
+
// finds it at the desk. And it confirms what was SHOWN - see
|
|
2403
|
+
// `shownPreviews`.
|
|
2404
|
+
// Keyed by what the change COMES TO, not how it was asked: the card sends
|
|
2405
|
+
// its confirmation from the preview's own nights and rooms, which need not
|
|
2406
|
+
// be the words the preview was asked in.
|
|
2407
|
+
const shownKey = [
|
|
2408
|
+
key,
|
|
2409
|
+
'change',
|
|
2410
|
+
preview.bookingId,
|
|
2411
|
+
preview.to?.stay.checkIn,
|
|
2412
|
+
preview.to?.stay.checkOut,
|
|
2413
|
+
preview.to?.rooms,
|
|
2414
|
+
preview.to?.roomName,
|
|
2415
|
+
preview.addOn ? `${preview.addOn.id}:${preview.addOn.to}` : '',
|
|
2416
|
+
].join('|');
|
|
2417
|
+
const wasShown = previewsOf(deps).get(shownKey);
|
|
2418
|
+
const newTotal = preview.to?.total.minor;
|
|
2419
|
+
if (newTotal !== undefined)
|
|
2420
|
+
remember(deps, shownKey, newTotal);
|
|
2185
2421
|
if (name === t.previewChange || args.confirmed !== true)
|
|
2186
2422
|
return asPreview;
|
|
2187
2423
|
if (!preview.changeable)
|
|
2188
2424
|
return asPreview;
|
|
2425
|
+
if (wasShown === undefined || wasShown !== newTotal) {
|
|
2426
|
+
return {
|
|
2427
|
+
...asPreview,
|
|
2428
|
+
text: [
|
|
2429
|
+
wasShown === undefined
|
|
2430
|
+
? 'Nothing has changed yet: this is what the change would do, for the guest to see first.'
|
|
2431
|
+
: `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.`,
|
|
2432
|
+
asPreview.text,
|
|
2433
|
+
].join(' '),
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2189
2436
|
try {
|
|
2190
2437
|
const changed = await deps.backend.changeBooking(ref, change, who);
|
|
2191
2438
|
if (!changed)
|
|
@@ -2219,11 +2466,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2219
2466
|
if (name === t.previewCancellation || name === t.cancelBooking) {
|
|
2220
2467
|
const ref = str(args.booking);
|
|
2221
2468
|
const who = identity(deps, ctx, key, args);
|
|
2222
|
-
const preview = await deps.backend.previewCancellation(ref, who);
|
|
2469
|
+
const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewCancellation(ref, who));
|
|
2223
2470
|
if (!preview) {
|
|
2224
2471
|
return {
|
|
2225
|
-
structured: {
|
|
2226
|
-
|
|
2472
|
+
structured: {
|
|
2473
|
+
view: 'booking-not-found',
|
|
2474
|
+
...common(deps, key),
|
|
2475
|
+
query: ref,
|
|
2476
|
+
...(tooMany ? { tooMany: true } : {}),
|
|
2477
|
+
},
|
|
2478
|
+
text: tooMany
|
|
2479
|
+
? TOO_MANY_TRIES
|
|
2480
|
+
: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
2227
2481
|
};
|
|
2228
2482
|
}
|
|
2229
2483
|
const asPreview = {
|
|
@@ -2244,17 +2498,33 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2244
2498
|
preview.cancellable
|
|
2245
2499
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
2246
2500
|
: '',
|
|
2501
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2247
2502
|
]
|
|
2248
2503
|
.filter(Boolean)
|
|
2249
2504
|
.join(' '),
|
|
2250
2505
|
};
|
|
2251
2506
|
// ⚠️ THE PREVIEW IS NOT ADVISORY. Cancelling without one is how a guest
|
|
2252
2507
|
// 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
|
|
2508
|
+
// returns this same preview and does nothing until it is told again - and
|
|
2509
|
+
// told again about the fee that was SHOWN (`shownPreviews`).
|
|
2510
|
+
const shownKey = `${key}|cancel|${preview.bookingId}`;
|
|
2511
|
+
const wasShown = previewsOf(deps).get(shownKey);
|
|
2512
|
+
remember(deps, shownKey, preview.fee.minor);
|
|
2254
2513
|
if (name === t.previewCancellation || args.confirmed !== true)
|
|
2255
2514
|
return asPreview;
|
|
2256
2515
|
if (!preview.cancellable)
|
|
2257
2516
|
return asPreview;
|
|
2517
|
+
if (wasShown === undefined || wasShown !== preview.fee.minor) {
|
|
2518
|
+
return {
|
|
2519
|
+
...asPreview,
|
|
2520
|
+
text: [
|
|
2521
|
+
wasShown === undefined
|
|
2522
|
+
? 'Nothing is cancelled yet: this is what cancelling would cost, for the guest to see first.'
|
|
2523
|
+
: `The fee has changed since it was shown - it is now ${formatMoney(preview.fee)}. Nothing is cancelled yet.`,
|
|
2524
|
+
asPreview.text,
|
|
2525
|
+
].join(' '),
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2258
2528
|
const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
|
|
2259
2529
|
if (!cancelled)
|
|
2260
2530
|
return asPreview;
|
|
@@ -2268,7 +2538,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2268
2538
|
rule: preview.rule,
|
|
2269
2539
|
cancellable: false,
|
|
2270
2540
|
},
|
|
2271
|
-
text:
|
|
2541
|
+
text: [
|
|
2542
|
+
`${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
|
|
2543
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2544
|
+
]
|
|
2545
|
+
.filter(Boolean)
|
|
2546
|
+
.join(' '),
|
|
2272
2547
|
};
|
|
2273
2548
|
}
|
|
2274
2549
|
if (name === t.compare) {
|
|
@@ -2282,6 +2557,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2282
2557
|
stay: asStay(args),
|
|
2283
2558
|
recommended: str(args.recommended) || undefined,
|
|
2284
2559
|
title: str(args.title) || undefined,
|
|
2560
|
+
// The member's rate, like the list and the hotel beside it: without
|
|
2561
|
+
// it the comparison quoted HK$5,424 where both said HK$4,610.
|
|
2562
|
+
who: identity(deps, ctx, key),
|
|
2285
2563
|
});
|
|
2286
2564
|
if (comparison.columns.length === 0) {
|
|
2287
2565
|
return {
|
|
@@ -2357,7 +2635,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2357
2635
|
if (name === t.signIn) {
|
|
2358
2636
|
const number = str(args.number);
|
|
2359
2637
|
const surname = str(args.surname);
|
|
2638
|
+
// Counted like a booking lookup - see limits.ts. A number and a surname
|
|
2639
|
+
// read a tier, a point balance and saved preferences.
|
|
2640
|
+
const tries = [`signin|${key}`, `member|${number.replace(/\s+/g, '').toUpperCase()}`];
|
|
2641
|
+
if (deps.misses?.blocked(tries)) {
|
|
2642
|
+
return {
|
|
2643
|
+
structured: {
|
|
2644
|
+
view: 'member-not-found',
|
|
2645
|
+
...common(deps, key),
|
|
2646
|
+
query: number,
|
|
2647
|
+
tooMany: true,
|
|
2648
|
+
},
|
|
2649
|
+
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.',
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2360
2652
|
const member = await deps.backend.signInMember(number, surname);
|
|
2653
|
+
if (!member)
|
|
2654
|
+
deps.misses?.miss(tries);
|
|
2361
2655
|
if (!member) {
|
|
2362
2656
|
// ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
|
|
2363
2657
|
// enough, have the discount" - is inventing a membership, and the guest
|
|
@@ -2403,6 +2697,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2403
2697
|
await deps.backend.rememberPreferences(m, []);
|
|
2404
2698
|
const { preferences: _gone, ...member } = m;
|
|
2405
2699
|
deps.members.set(key, member);
|
|
2700
|
+
deps.members.setPreferences(m.number, []);
|
|
2406
2701
|
return {
|
|
2407
2702
|
structured: { view: 'member', ...common(deps, key), member },
|
|
2408
2703
|
text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
|