@curless/hotel-storefront 0.45.2 → 0.47.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/tools.js CHANGED
@@ -7,6 +7,7 @@ import { amountsIn, formatMoney, keepingAmounts, money, multiplyMoney, toMinor }
7
7
  import { roomNamesOf, withTraditional } from './names.js';
8
8
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
9
9
  import { regionOf } from './regions.js';
10
+ import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
10
11
  import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, shownOf, tripKey, } from './trip.js';
11
12
  import { DEMO_NOTE, branded, catalogue, noteFor, upFirst } from './vocab.js';
12
13
  // The eleven things a guest can do: find hotels, look at one on dates, see what
@@ -259,7 +260,7 @@ const allTools = (n, v) => {
259
260
  return [
260
261
  {
261
262
  name: n.listHotels,
262
- description: `${upFirst(catalogue(v))}, 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}` : ''}`,
263
+ description: `${upFirst(catalogue(v))}, 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\`, \`breakfast\` and \`bed\` 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}` : ''}`,
263
264
  annotations: reads(`Browse ${catalogue(v)}`, open),
264
265
  inputSchema: {
265
266
  type: 'object',
@@ -324,6 +325,11 @@ const allTools = (n, v) => {
324
325
  type: 'boolean',
325
326
  description: `Only ${v.nounPlural} with a room that includes breakfast - and, with dates, that room is the one priced`,
326
327
  },
328
+ bed: {
329
+ type: 'string',
330
+ enum: ['king', 'twin'],
331
+ description: `Only ${v.nounPlural} with a room that has this bed - \`king\` is one large bed (king, queen or double), \`twin\` is two beds - and, with dates, that room is the one priced. Given with \`freeCancellation\` or \`breakfast\`, one room must have all of them`,
332
+ },
327
333
  all: {
328
334
  type: 'boolean',
329
335
  description: `Unnarrowed, or narrowed to a country of many cities, the answer is the places to choose from; true lists the ${v.nounPlural} themselves`,
@@ -333,7 +339,7 @@ const allTools = (n, v) => {
333
339
  },
334
340
  {
335
341
  name: n.getHotel,
336
- description: `One ${v.noun} in full: description, amenities, address, and check-in and check-out times where the ${v.noun} publishes them. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known. The ${v.noun} is drawn as a card for the guest to look at.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
342
+ description: `One ${v.noun} in full: description, amenities, address, and check-in and check-out times where the ${v.noun} publishes them. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, one line per type at its lowest rate: the nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rates it has. \`rooms\` leaves out the types that have fewer than that many left. \`bed\`, \`breakfast\`, \`freeCancellation\`, \`window\`, \`payment\` and \`family\` keep only the rates that meet all of those given; the card still holds every room, with the given filters selected. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known. The ${v.noun} is drawn as a card for the guest to look at.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
337
343
  annotations: reads(`View one ${catalogue(v, true)}`, open),
338
344
  inputSchema: {
339
345
  type: 'object',
@@ -349,6 +355,32 @@ const allTools = (n, v) => {
349
355
  maximum: MAX_ROOMS_PER_LINE,
350
356
  description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
351
357
  },
358
+ bed: {
359
+ type: 'string',
360
+ enum: ['king', 'twin'],
361
+ description: 'With dates: only rooms with this bed - `king` is one large bed (king, queen or double), `twin` is two beds. A room sold as king or twin has both',
362
+ },
363
+ breakfast: {
364
+ type: 'boolean',
365
+ description: 'With dates: only rooms whose rate includes breakfast',
366
+ },
367
+ freeCancellation: {
368
+ type: 'boolean',
369
+ description: 'With dates: only rooms that can be cancelled free',
370
+ },
371
+ window: {
372
+ type: 'boolean',
373
+ description: 'With dates: only rooms with a window (not a partial or an inner one)',
374
+ },
375
+ payment: {
376
+ type: 'string',
377
+ enum: ['at-hotel', 'online'],
378
+ description: 'With dates: only rooms paid at the hotel (`at-hotel`) or in advance online (`online`)',
379
+ },
380
+ family: {
381
+ type: 'boolean',
382
+ description: 'With dates: only rooms that sleep 3 or more',
383
+ },
352
384
  },
353
385
  required: ['hotel'],
354
386
  },
@@ -1029,6 +1061,24 @@ const HOTEL_PAGE = {
1029
1061
  policies: ARR,
1030
1062
  /** Arrivals a few days either side, each re-quoted for the same nights - what the date picker prices its days from. */
1031
1063
  dates: ARR,
1064
+ /**
1065
+ * The room filters this hotel's rooms offer, each with how many rooms (rates)
1066
+ * it keeps - `{ key, count }`, `key` one of `breakfast`, `king`, `twin`,
1067
+ * `freeCancellation` (always here, `count` 0 where no room has it) and
1068
+ * `window`, `payAtHotel`, `payOnline`, `family` (here only where some rooms
1069
+ * have it and some do not).
1070
+ */
1071
+ roomFilters: ARR,
1072
+ /** The filters the call asked for, as the same keys - the card opens with these selected. */
1073
+ activeFilters: STRS,
1074
+ /**
1075
+ * Room types with no room to book for these nights (none left, or fewer
1076
+ * than the rooms asked for): `{ id, name, names?, sleeps, ..., left? }`, no
1077
+ * price. The card draws them muted at the end of the list, so a type the
1078
+ * guest saw is said to be sold out and not left out. Absent where there are
1079
+ * none - and never in what the assistant is told.
1080
+ */
1081
+ soldOut: ARR,
1032
1082
  };
1033
1083
  export const buildOutputSchemas = (n) => ({
1034
1084
  [n.listHotels]: {
@@ -1146,6 +1196,8 @@ export const buildOutputSchemas = (n) => ({
1146
1196
  accessibility: BOOL,
1147
1197
  /** One per stay, when the trip held several. */
1148
1198
  bookings: ARR,
1199
+ /** What all of `bookings` come to, where they are in one currency (the card's first screen). */
1200
+ totalAll: STR,
1149
1201
  /**
1150
1202
  * The stays that could not be booked when the others were - each with
1151
1203
  * why, still in the trip, and what could be booked instead (robin,
@@ -1965,6 +2017,107 @@ const labelWords = (r) => {
1965
2017
  ];
1966
2018
  return words.length ? ` [${words.join(', ')}]` : '';
1967
2019
  };
2020
+ /** What a rate says about cancelling, as the text puts it. */
2021
+ const cancelWords = (r) => r.freeCancellationUntil
2022
+ ? `free to cancel until ${freeUntilText(r)}`
2023
+ : r.refundable
2024
+ ? 'the free cancellation window has closed - cancelling now has a fee'
2025
+ : 'non-refundable';
2026
+ /**
2027
+ * What a room is, in a parenthesis: its beds, how many it sleeps and - for one
2028
+ * rate, not a type whose rates differ - breakfast, unless the rate's labels
2029
+ * already say it.
2030
+ */
2031
+ const roomFactsWords = (r, rate = false) => {
2032
+ const facts = [
2033
+ r.beds,
2034
+ `sleeps ${r.sleeps}`,
2035
+ rate && facetsOf(r).breakfast && !(r.labels ?? []).some((l) => l.startsWith('breakfast'))
2036
+ ? 'breakfast included'
2037
+ : '',
2038
+ ]
2039
+ .filter(Boolean)
2040
+ .join(', ');
2041
+ return ` (${facts})`;
2042
+ };
2043
+ /** The cheapest price a rate quotes, as the text says it: "from" a night, or the hours of a block. */
2044
+ const fromWords = (r) => r.hourly
2045
+ ? `${formatMoney(r.stayTotal)} for ${r.hourly.hours} hours incl. taxes and fees`
2046
+ : `${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2047
+ /**
2048
+ * A hotel's rooms for the nights, one line per ROOM TYPE.
2049
+ *
2050
+ * ⚠️ A TYPE IS ONE LINE, NOT ONE LINE PER RATE CODE. A Huazhu hotel sells a
2051
+ * dozen rates on three types, and every code in one long run buried the types
2052
+ * (robin, 2026-10-23, after Qunar's page). A type with one rate is that rate,
2053
+ * in full; a type with several says how many, its lowest price, which rate that
2054
+ * is - the name `room` takes - and what the rest have, in a few words.
2055
+ * Another rate is reached by narrowing: `bed`, `breakfast`, `freeCancellation`.
2056
+ */
2057
+ const roomTypeLines = (rooms, roomsWanted,
2058
+ /** The terms already applied: what they say is true of every rate here, so it is not counted again. */
2059
+ terms) => {
2060
+ const forRooms = (r) => roomsWanted > 1
2061
+ ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)`
2062
+ : '';
2063
+ return byRoomType(rooms).map((type) => {
2064
+ const cheapest = type.rooms.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a));
2065
+ const n = type.rooms.length;
2066
+ if (n === 1)
2067
+ return `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
2068
+ const some = (k, all, part) => k === n ? all : k > 0 ? `${k} of ${n} ${part}` : '';
2069
+ const also = [
2070
+ terms?.breakfast
2071
+ ? ''
2072
+ : some(type.rooms.filter((r) => facetsOf(r).breakfast).length, 'all include breakfast', 'include breakfast'),
2073
+ terms?.freeCancellation
2074
+ ? ''
2075
+ : some(type.rooms.filter((r) => facetsOf(r).freeCancellation).length, 'all are free to cancel', 'are free to cancel'),
2076
+ ].filter(Boolean);
2077
+ return `- ${type.name}${roomFactsWords(cheapest)} — ${n} rates from ${fromWords(cheapest)}${forRooms(cheapest)}. Cheapest: "${cheapest.name}"${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`;
2078
+ });
2079
+ };
2080
+ /**
2081
+ * What a hotel's rooms look like to the assistant for the nights: one line per
2082
+ * type, and with filters only the rates that meet ALL of them. None meeting
2083
+ * them is said plainly - which filters were asked, what each leaves on its own
2084
+ * - and that the full list is there without them.
2085
+ */
2086
+ const roomsText = (hotelName,
2087
+ /** "3 room types for 2026-11-04 to 2026-11-06, 2 guests" - what the rooms are, counted. */
2088
+ counted,
2089
+ /** "2026-11-04 to 2026-11-06" - when, for the sentence that says none fit. */
2090
+ when, enough, roomsWanted, terms) => {
2091
+ // A type with several rates is priced from its cheapest: say how to reach another.
2092
+ const lines = (rs) => [
2093
+ ...roomTypeLines(rs, roomsWanted, terms),
2094
+ ...(byRoomType(rs).some((t) => t.rooms.length > 1)
2095
+ ? [
2096
+ 'A type with several rates is priced from its cheapest one; bed, breakfast, freeCancellation, window, payment and family narrow to the others (the card lists every rate).',
2097
+ ]
2098
+ : []),
2099
+ ];
2100
+ if (!terms)
2101
+ return [`**${hotelName}** · ${counted(enough)}.`, ...lines(enough)];
2102
+ const asked = termWords(terms);
2103
+ const several = activeKeysOf(terms).length > 1;
2104
+ const rates = (n) => `${n} ${n === 1 ? 'rate' : 'rates'}`;
2105
+ const matching = enough.filter((r) => matchesTerms(r, terms));
2106
+ if (matching.length === 0) {
2107
+ const alone = several
2108
+ ? ` On its own, ${activeKeysOf(terms)
2109
+ .map((k) => `${keyWords(k)} fits ${enough.filter((r) => facetsOf(r)[k]).length}`)
2110
+ .join(', ')}.`
2111
+ : '';
2112
+ return [
2113
+ `**${hotelName}** · none of the ${rates(enough.length)} for ${when} has ${asked}${several ? ' together' : ''}.${alone} The full list is there without ${several ? 'those filters' : 'that filter'}.`,
2114
+ ];
2115
+ }
2116
+ return [
2117
+ `**${hotelName}** · ${counted(matching)}, with ${asked} (${matching.length} of ${rates(enough.length)}).`,
2118
+ ...lines(matching),
2119
+ ];
2120
+ };
1968
2121
  const PAYMENT_WORDS = {
1969
2122
  awaiting: 'to be paid on the card page',
1970
2123
  issued: 'confirmed on UnionPay - the hotel is being asked to book it',
@@ -2093,6 +2246,52 @@ const dateFlex = (dates, stay) => {
2093
2246
  text: `Arriving ${beats.checkIn} instead is ${formatMoney(saved)} less for ${sameNights}.`,
2094
2247
  };
2095
2248
  };
2249
+ /**
2250
+ * The room types a guest can no longer book on these nights - for the card to
2251
+ * say so, and not leave them out (r10 #10: a type the guest saw earlier
2252
+ * vanished from the list, and the heading counted one fewer, without a word).
2253
+ *
2254
+ * ⚠️ AN ADDITIVE FIELD, NEVER THE TEXT. What the assistant is told is the rooms
2255
+ * that can be booked, as before; this is only what the card draws, muted, at
2256
+ * the end of the list. A type is listed when
2257
+ * - the backend sends it with none left (`HotelDetail.soldOut`, or a room with
2258
+ * `roomsLeft: 0`), or
2259
+ * - it has some left, but fewer than the rooms asked for (`left`): a guest who
2260
+ * asked for two rooms and was shown no type with one left was not told why.
2261
+ * Not a type that sleeps fewer than the party (the backend never sends it), and
2262
+ * not a type any of whose rates can be booked. One entry a type, with the facts
2263
+ * the card says of a room and no price.
2264
+ */
2265
+ const soldOutTypesOf = (detail, enough, wanted) => {
2266
+ const key = (r) => r.roomType?.id ?? r.id;
2267
+ const open = new Set(enough.map(key));
2268
+ const types = new Map();
2269
+ for (const r of [...(detail.rooms ?? []), ...(detail.soldOut ?? [])]) {
2270
+ if (r.hourly || open.has(key(r)) || r.roomsLeft >= wanted)
2271
+ continue;
2272
+ const seen = types.get(key(r));
2273
+ if (seen)
2274
+ seen.left = Math.max(seen.left, r.roomsLeft);
2275
+ else
2276
+ types.set(key(r), { room: r, left: r.roomsLeft });
2277
+ }
2278
+ return [...types.entries()].map(([id, { room: r, left }]) => ({
2279
+ id,
2280
+ name: r.roomType?.name ?? r.name,
2281
+ ...((r.roomType?.names ?? r.names) ? { names: r.roomType?.names ?? r.names } : {}),
2282
+ sleeps: r.sleeps,
2283
+ ...(r.maxChildren ? { maxChildren: r.maxChildren } : {}),
2284
+ ...(r.sizeSqm ? { sizeSqm: r.sizeSqm } : {}),
2285
+ ...(r.beds ? { beds: r.beds } : {}),
2286
+ ...(r.bedTypes ? { bedTypes: r.bedTypes } : {}),
2287
+ ...(r.view ? { view: r.view } : {}),
2288
+ ...(r.window ? { window: r.window } : {}),
2289
+ ...(r.floor ? { floor: r.floor } : {}),
2290
+ ...(r.imageUrl ? { imageUrl: r.imageUrl } : {}),
2291
+ // Some left, but not as many as were asked for.
2292
+ ...(left > 0 ? { left } : {}),
2293
+ }));
2294
+ };
2096
2295
  const identity = (deps, ctx, key, args = {}) => {
2097
2296
  const email = str(args.email);
2098
2297
  const member = deps.members.read(key);
@@ -2381,6 +2580,7 @@ const filtersOf = (args) => {
2381
2580
  ...(facilities.length ? { facilities } : {}),
2382
2581
  ...(args.freeCancellation === true ? { freeCancellation: true } : {}),
2383
2582
  ...(args.breakfast === true ? { breakfast: true } : {}),
2583
+ ...(args.bed === 'king' || args.bed === 'twin' ? { bed: args.bed } : {}),
2384
2584
  ...(args.hourly === true ? { hourly: true } : {}),
2385
2585
  };
2386
2586
  return Object.keys(f).length ? f : undefined;
@@ -2391,6 +2591,7 @@ const filterWords = (f) => [
2391
2591
  ...(f.facilities ?? []).map((k) => FACILITIES.find((x) => x.key === k)?.label.toLowerCase()),
2392
2592
  f.freeCancellation ? 'free cancellation' : '',
2393
2593
  f.breakfast ? 'breakfast included' : '',
2594
+ f.bed === 'king' ? 'king beds' : f.bed === 'twin' ? 'twin beds' : '',
2394
2595
  f.hourly ? 'rooms by the hour today' : '',
2395
2596
  ]
2396
2597
  .filter(Boolean)
@@ -2401,6 +2602,7 @@ const emptiedBy = (f, before, nouns) => {
2401
2602
  (f.facilities?.length ?? 0) +
2402
2603
  (f.freeCancellation ? 1 : 0) +
2403
2604
  (f.breakfast ? 1 : 0) +
2605
+ (f.bed ? 1 : 0) +
2404
2606
  // By the hour is a term like the others: alone, it is said alone - not
2405
2607
  // "has rooms by the hour today together" (huazhu, 2026-10-13).
2406
2608
  (f.hourly ? 1 : 0);
@@ -3022,7 +3224,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3022
3224
  const applied = (wantedPrice && !oneCurrency) || answer.ignored?.sort ? 'group' : sort;
3023
3225
  // Room terms the backend could not apply are not "filtered to".
3024
3226
  const filtersSaid = asked.filters && answer.ignored?.roomTerms
3025
- ? { ...asked.filters, freeCancellation: undefined, breakfast: undefined }
3227
+ ? { ...asked.filters, freeCancellation: undefined, breakfast: undefined, bed: undefined }
3026
3228
  : asked.filters;
3027
3229
  const priceNotes = [
3028
3230
  // What the backend was asked and did not do, in its own words.
@@ -3125,7 +3327,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3125
3327
  };
3126
3328
  if (!stay) {
3127
3329
  return {
3128
- structured: { view: 'hotel', ...base, needsDates: true },
3330
+ // ⚠️ THE FILTERS RIDE ALONG WITHOUT DATES: the card prices the dates
3331
+ // itself and draws its chips from this answer, so a model that asked
3332
+ // for breakfast before any night was named still has it asked.
3333
+ structured: {
3334
+ view: 'hotel',
3335
+ ...base,
3336
+ needsDates: true,
3337
+ activeFilters: activeKeysOf(roomTermsOf(args)),
3338
+ },
3129
3339
  text: [
3130
3340
  `**${h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
3131
3341
  description.fenced,
@@ -3185,8 +3395,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3185
3395
  .join(' '),
3186
3396
  };
3187
3397
  }
3398
+ // ⚠️ THE CARD KEEPS EVERY ROOM, THE TEXT KEEPS THE ONES THAT MATCH. The
3399
+ // filters narrow what the assistant is told; the card is sent all of them
3400
+ // with what is true of each (`facets`), so a chip switched on or off on the
3401
+ // card needs no call to the server.
3402
+ const terms = roomTermsOf(args);
3403
+ const soldOut = soldOutTypesOf(detail, enough, roomsWanted);
3188
3404
  const rooms = enough.map((r) => ({
3189
3405
  ...r,
3406
+ facets: facetsOf(r),
3190
3407
  nightly: formatMoney(r.nightly),
3191
3408
  stayTotal: formatMoney(r.stayTotal),
3192
3409
  taxesAndFees: formatMoney(r.taxesAndFees),
@@ -3219,14 +3436,20 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3219
3436
  stay,
3220
3437
  rooms,
3221
3438
  rooms_wanted: roomsWanted,
3439
+ roomFilters: optionsOf(enough),
3440
+ activeFilters: activeKeysOf(terms),
3441
+ // Types with no room to book for these nights, said and not left out.
3442
+ ...(byTheHour || !soldOut.length ? {} : { soldOut }),
3222
3443
  ...flex.structured,
3223
3444
  },
3224
3445
  text: [
3225
- byTheHour
3226
- ? `**${h.name}** · ${enough.length} ${enough.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests.`
3227
- : `**${h.name}** · ${offerCount(enough)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
3228
- ...enough.map((r) => `- ${r.name}${labelWords(r)} — ${priceWords(r)}${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? ', the free cancellation window has closed - cancelling now has a fee' : ', non-refundable'}`),
3229
- flex.text,
3446
+ ...roomsText(h.name, byTheHour
3447
+ ? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
3448
+ : (rs) => `${offerCount(rs)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}`, byTheHour ? `today, ${stay.checkIn}` : `${stay.checkIn} to ${stay.checkOut}`, enough, roomsWanted, terms),
3449
+ // The other arrivals are priced on the cheapest room of any kind.
3450
+ terms && flex.text
3451
+ ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
3452
+ : flex.text,
3230
3453
  deps.vocab.demo ? noteFor(deps.vocab) : '',
3231
3454
  ]
3232
3455
  .filter(Boolean)
@@ -3869,6 +4092,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3869
4092
  : {}),
3870
4093
  paid: false,
3871
4094
  ...(remembered ? { remembered: remembered.kept } : {}),
4095
+ // What they come to together, said by the server like every figure on
4096
+ // the card - never added up there (r10, #15). Only in one currency.
4097
+ ...(several &&
4098
+ booked.every(({ booking }) => booking.quote.total.currency === first.booking.quote.total.currency)
4099
+ ? {
4100
+ totalAll: formatMoney({
4101
+ minor: booked.reduce((n, { booking }) => n + booking.quote.total.minor, 0),
4102
+ currency: first.booking.quote.total.currency,
4103
+ }),
4104
+ }
4105
+ : {}),
3872
4106
  holdUntil: first.booking.holdUntil,
3873
4107
  total: formatMoney(first.booking.quote.total),
3874
4108
  ...(first.asked.length ? { requested: first.asked } : {}),