@curless/hotel-storefront 0.27.28 → 0.29.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.
Files changed (93) hide show
  1. package/dist/backend.d.ts +19 -0
  2. package/dist/backend.d.ts.map +1 -1
  3. package/dist/backend.js.map +1 -1
  4. package/dist/card.generated.d.ts +1 -1
  5. package/dist/card.generated.js +2 -2
  6. package/dist/card.generated.js.map +1 -1
  7. package/dist/config.d.ts +45 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/demo-hotels.d.ts.map +1 -1
  11. package/dist/demo-hotels.js +7 -1
  12. package/dist/demo-hotels.js.map +1 -1
  13. package/dist/hourly.d.ts +31 -0
  14. package/dist/hourly.d.ts.map +1 -0
  15. package/dist/hourly.js +61 -0
  16. package/dist/hourly.js.map +1 -0
  17. package/dist/huazhu/backend.d.ts +13 -1
  18. package/dist/huazhu/backend.d.ts.map +1 -1
  19. package/dist/huazhu/backend.js +712 -39
  20. package/dist/huazhu/backend.js.map +1 -1
  21. package/dist/huazhu/bookings.d.ts +37 -1
  22. package/dist/huazhu/bookings.d.ts.map +1 -1
  23. package/dist/huazhu/bookings.js.map +1 -1
  24. package/dist/huazhu/check.js +2 -2
  25. package/dist/huazhu/check.js.map +1 -1
  26. package/dist/huazhu/client.d.ts +3 -3
  27. package/dist/huazhu/client.d.ts.map +1 -1
  28. package/dist/huazhu/client.js +4 -4
  29. package/dist/huazhu/client.js.map +1 -1
  30. package/dist/huazhu/english.d.ts.map +1 -1
  31. package/dist/huazhu/english.js +15 -0
  32. package/dist/huazhu/english.js.map +1 -1
  33. package/dist/huazhu/fake.d.ts +3 -1
  34. package/dist/huazhu/fake.d.ts.map +1 -1
  35. package/dist/huazhu/fake.js +69 -7
  36. package/dist/huazhu/fake.js.map +1 -1
  37. package/dist/huazhu/labels.d.ts +9 -0
  38. package/dist/huazhu/labels.d.ts.map +1 -0
  39. package/dist/huazhu/labels.js +28 -0
  40. package/dist/huazhu/labels.js.map +1 -0
  41. package/dist/huazhu/make.d.ts.map +1 -1
  42. package/dist/huazhu/make.js +26 -0
  43. package/dist/huazhu/make.js.map +1 -1
  44. package/dist/huazhu/sandbox-photos.d.ts +7 -0
  45. package/dist/huazhu/sandbox-photos.d.ts.map +1 -1
  46. package/dist/huazhu/sandbox-photos.js +7 -0
  47. package/dist/huazhu/sandbox-photos.js.map +1 -1
  48. package/dist/huazhu/sandbox.d.ts +2 -0
  49. package/dist/huazhu/sandbox.d.ts.map +1 -1
  50. package/dist/huazhu/sandbox.js +125 -6
  51. package/dist/huazhu/sandbox.js.map +1 -1
  52. package/dist/huazhu/sellable.d.ts +46 -3
  53. package/dist/huazhu/sellable.d.ts.map +1 -1
  54. package/dist/huazhu/sellable.js +105 -12
  55. package/dist/huazhu/sellable.js.map +1 -1
  56. package/dist/huazhu/wire.d.ts +11 -0
  57. package/dist/huazhu/wire.d.ts.map +1 -1
  58. package/dist/huazhu/wire.js.map +1 -1
  59. package/dist/legal.d.ts.map +1 -1
  60. package/dist/legal.js +19 -8
  61. package/dist/legal.js.map +1 -1
  62. package/dist/payments/checkout.d.ts +71 -0
  63. package/dist/payments/checkout.d.ts.map +1 -0
  64. package/dist/payments/checkout.js +133 -0
  65. package/dist/payments/checkout.js.map +1 -0
  66. package/dist/payments/fake-gateway.d.ts +43 -0
  67. package/dist/payments/fake-gateway.d.ts.map +1 -0
  68. package/dist/payments/fake-gateway.js +135 -0
  69. package/dist/payments/fake-gateway.js.map +1 -0
  70. package/dist/present.d.ts.map +1 -1
  71. package/dist/present.js +8 -4
  72. package/dist/present.js.map +1 -1
  73. package/dist/server.d.ts +4 -2
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +19 -4
  76. package/dist/server.js.map +1 -1
  77. package/dist/supplier.d.ts +2 -0
  78. package/dist/supplier.d.ts.map +1 -1
  79. package/dist/supplier.js.map +1 -1
  80. package/dist/surface.d.ts.map +1 -1
  81. package/dist/surface.js +7 -2
  82. package/dist/surface.js.map +1 -1
  83. package/dist/tools.d.ts +15 -1
  84. package/dist/tools.d.ts.map +1 -1
  85. package/dist/tools.js +298 -30
  86. package/dist/tools.js.map +1 -1
  87. package/dist/trip.d.ts +6 -0
  88. package/dist/trip.d.ts.map +1 -1
  89. package/dist/trip.js +17 -3
  90. package/dist/trip.js.map +1 -1
  91. package/dist/types.d.ts +109 -1
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -2,6 +2,7 @@ import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KI
2
2
  import { MAX_GUESTS, isDay, nights as nightsBetween } from './dates.js';
3
3
  import { FACILITIES } from './facilities.js';
4
4
  import { DESCRIPTION_MAX, POLICY_MAX, guestText, hotelText } from './fence.js';
5
+ import { clockOf, minutesOf, startProblem, stayWords, windowWords } from './hourly.js';
5
6
  import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
6
7
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
7
8
  import { regionOf } from './regions.js';
@@ -131,6 +132,46 @@ const forCapabilities = (tools, n, v, caps) => {
131
132
  out = JSON.parse(JSON.stringify(out).replaceAll(' and add-ons', ''));
132
133
  }
133
134
  }
135
+ // ⚠️ ONLY WHERE THERE ARE ROOMS BY THE HOUR: a connector that sells none
136
+ // lists no parameter for them (总线程, 2026-10-12: inputs only gain
137
+ // optional parameters, the descriptions only say facts).
138
+ if (caps.hourly) {
139
+ const withProps = (schema, props) => {
140
+ const s = schema;
141
+ return { ...s, properties: { ...(s.properties ?? {}), ...props } };
142
+ };
143
+ if (t.name === n.getHotel)
144
+ out = {
145
+ ...out,
146
+ inputSchema: withProps(out.inputSchema, {
147
+ hourly: {
148
+ type: 'boolean',
149
+ description: "Today's rooms by the hour, each priced for its hours. checkIn and checkOut are not used: rooms by the hour are sold for today only.",
150
+ },
151
+ }),
152
+ };
153
+ if (t.name === n.listHotels)
154
+ out = {
155
+ ...out,
156
+ inputSchema: withProps(out.inputSchema, {
157
+ hourly: {
158
+ type: 'boolean',
159
+ description: `Only ${v.nounPlural} with a room by the hour today, each priced by its cheapest block.`,
160
+ },
161
+ }),
162
+ };
163
+ if (t.name === n.addToTrip)
164
+ out = {
165
+ ...out,
166
+ inputSchema: withProps(out.inputSchema, {
167
+ startTime: {
168
+ type: 'string',
169
+ pattern: '^([01]?\\d|2[0-3]):[0-5]\\d$',
170
+ description: 'For a room by the hour: when the guest arrives today, HH:MM, within the start times the room lists. The date is today; checkOut is not used.',
171
+ },
172
+ }),
173
+ };
174
+ }
134
175
  if (t.name === n.requestBooking) {
135
176
  if (caps.requiresPhone) {
136
177
  const schema = out.inputSchema;
@@ -167,7 +208,9 @@ const forCapabilities = (tools, n, v, caps) => {
167
208
  // itself: the instructions carried "test environment" while this
168
209
  // line still promised "the hotel's own reservation" (2026-09-30).
169
210
  description: v.sandbox
170
- ? `Makes a booking in a simulated copy of ${v.brand}'s booking system, for testing: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. No payment is taken and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
211
+ ? `Makes a booking in a simulated copy of ${v.brand}'s booking system, for testing: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. ${caps.prepaid
212
+ ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
213
+ : 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
171
214
  : `Books the trip's rooms with ${v.brand} for a named guest: each stay becomes the hotel's own reservation, with its own confirmation code, paid at the hotel. No payment is taken here and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
172
215
  };
173
216
  }
@@ -823,13 +866,13 @@ const CHANGE_SCHEMA = {
823
866
  * policies, and every trip a member badge and saving (2026-10-05 audit) -
824
867
  * fields no answer there ever carries.
825
868
  */
826
- export const outputSchemasFor = (schemas, caps) => {
869
+ export const outputSchemasFor = (schemas, caps, lang, also = {}) => {
827
870
  const gone = [
828
871
  ...(caps.members ? [] : ['member', 'memberSaving']),
829
872
  ...(caps.extras ? [] : ['extras']),
830
873
  ...(caps.policies === false ? ['policies'] : []),
831
874
  ];
832
- if (!gone.length)
875
+ if (!gone.length && !caps.hourly && !lang && !also.logoDark && !also.accent)
833
876
  return schemas;
834
877
  return Object.fromEntries(Object.entries(schemas).map(([name, schema]) => {
835
878
  const s = schema;
@@ -838,6 +881,18 @@ export const outputSchemasFor = (schemas, caps) => {
838
881
  const properties = { ...s.properties };
839
882
  for (const k of gone)
840
883
  delete properties[k];
884
+ // On every answer, as `common` puts it: rooms by the hour are sold here.
885
+ if (caps.hourly)
886
+ properties.hourly = BOOL;
887
+ // On every answer too: the language the card draws its own words in.
888
+ if (lang)
889
+ properties.lang = { enum: ['zh', 'en'] };
890
+ // And the dark page's logo, where the group gave one.
891
+ if (also.logoDark)
892
+ properties.logoDarkUrl = STR;
893
+ // And the group's own colour, where its deployment gives one.
894
+ if (also.accent)
895
+ properties.accent = { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' };
841
896
  return [name, { ...s, properties }];
842
897
  }));
843
898
  };
@@ -1145,7 +1200,48 @@ const holderOf = (deps, supplier) => {
1145
1200
  * room - and a booking whose confirmation has not come back yet is neither,
1146
1201
  * and is said as exactly that.
1147
1202
  */
1203
+ /**
1204
+ * A booking as the card is given it: its money as strings, like every other
1205
+ * price - the card never does arithmetic, and an amount sent as
1206
+ * `{ minor, currency }` was drawn as "[object Object]" (香格里拉, 2026-10-12).
1207
+ */
1208
+ const bookingForCard = (b) => ({
1209
+ ...b,
1210
+ quote: quoteForCard(b.quote),
1211
+ ...(b.payment ? { payment: { ...b.payment, amount: formatMoney(b.payment.amount) } } : {}),
1212
+ });
1213
+ /**
1214
+ * A booking paid here in advance, as the guest is told it - by where its
1215
+ * money stands. Undefined for any other booking.
1216
+ */
1217
+ const paidLine = (deps, b) => {
1218
+ const p = b.payment;
1219
+ if (!p)
1220
+ return undefined;
1221
+ const h = holderOf(deps, b.supplier);
1222
+ const amount = formatMoney(p.amount);
1223
+ const test = h.sandbox
1224
+ ? ' This is a test: the card page is in test mode, no real money moves, and the booking is in the simulated system.'
1225
+ : '';
1226
+ switch (p.status) {
1227
+ case 'awaiting':
1228
+ return `**Not booked yet, and nothing paid.** ${amount} is paid on the card page by ${p.payByLocal ?? p.payBy} hotel time: ${p.url}. Once it is paid the stay is booked with ${h.name}, and ${deps.tools.listBookings} shows it; unpaid by then, the page closes and nothing is booked or charged.${test}`;
1229
+ case 'paid':
1230
+ return b.status === 'confirmed'
1231
+ ? `${amount} paid in advance by card, and booked with ${h.name}: nothing more is paid at the hotel.${test}`
1232
+ : `${amount} paid in advance by card. ${h.name} has not confirmed the booking yet - ${deps.tools.listBookings} shows when it has; if it does not take it, all ${amount} goes back.${test}`;
1233
+ case 'lapsed':
1234
+ return `Not paid in time: nothing was booked and nothing was charged.${test}`;
1235
+ case 'refunding':
1236
+ return `All ${amount} is going back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.${test}`;
1237
+ case 'refunded':
1238
+ return `All ${amount} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.${test}`;
1239
+ }
1240
+ };
1148
1241
  const bookedLine = (deps, b) => {
1242
+ const paid = paidLine(deps, b);
1243
+ if (paid)
1244
+ return paid;
1149
1245
  const total = formatMoney(b.quote.total);
1150
1246
  const h = holderOf(deps, b.supplier);
1151
1247
  if (h.caps.booking === 'pay-at-hotel') {
@@ -1180,6 +1276,8 @@ const common = (deps, key) => {
1180
1276
  return {
1181
1277
  brand: deps.vocab.brand,
1182
1278
  ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
1279
+ ...(deps.logoDarkUrl ? { logoDarkUrl: deps.logoDarkUrl } : {}),
1280
+ ...(deps.accent ? { accent: deps.accent } : {}),
1183
1281
  ...(deps.showName ? { showName: true } : {}),
1184
1282
  toolNames: deps.offered
1185
1283
  ? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
@@ -1190,6 +1288,10 @@ const common = (deps, key) => {
1190
1288
  ...(deps.vocab.sandbox ? { sandbox: true } : {}),
1191
1289
  ...(caps.programme ? { programme: caps.programme } : {}),
1192
1290
  ...(caps.codePrefix ? { codePrefix: caps.codePrefix } : {}),
1291
+ // Sells rooms by the hour: the card draws its Overnight / By the hour
1292
+ // switch only then - it sees answers, never the tools' parameters (香格里拉,
1293
+ // 2026-10-12).
1294
+ ...(caps.hourly ? { hourly: true } : {}),
1193
1295
  // A market: the card draws each hotel and booking as its group's.
1194
1296
  ...(deps.backend.suppliers
1195
1297
  ? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
@@ -1197,8 +1299,19 @@ const common = (deps, key) => {
1197
1299
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1198
1300
  ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
1199
1301
  ...(key ? memberBadge(deps, key) : {}),
1302
+ ...(langOf(deps, key) ? { lang: langOf(deps, key) } : {}),
1200
1303
  };
1201
1304
  };
1305
+ /**
1306
+ * The language the CARD speaks to this guest - its own buttons, labels and
1307
+ * dates. What the server writes for the model stays English.
1308
+ *
1309
+ * ⚠️ PER TRIP, NOT ONLY PER BRAND (总线程, 2026-10-12): today it is the
1310
+ * deployment's (Huazhu's single store is Chinese), and a guest's own choice -
1311
+ * as the market's \`set_language\` keeps one per buyer - is meant to come in
1312
+ * HERE, by \`key\`, without the card or the schema changing.
1313
+ */
1314
+ const langOf = (deps, _key) => deps.lang;
1202
1315
  /**
1203
1316
  * When a room stops being free to cancel, the way a guest reads it: the
1204
1317
  * hotel's own clock when the backend knows it, else the instant in UTC - never
@@ -1220,6 +1333,7 @@ const quoteForCard = (quote, groupOf) => ({
1220
1333
  detail: l.detail,
1221
1334
  stay: l.stay,
1222
1335
  nights: l.nights,
1336
+ ...(l.hourly ? { hourly: l.hourly } : {}),
1223
1337
  quantity: l.quantity,
1224
1338
  // How many were ORDERED, beside how many units that comes to: one extra
1225
1339
  // bed over three nights is quantity 3 and ordered 1, and the card's
@@ -1229,6 +1343,12 @@ const quoteForCard = (quote, groupOf) => ({
1229
1343
  total: formatMoney(l.total),
1230
1344
  refundable: l.refundable,
1231
1345
  freeCancellationUntil: l.freeCancellationUntil,
1346
+ // The deadline on the hotel's clock, where the backend knows it - the
1347
+ // review restates the terms beside the total (round-2 jury).
1348
+ ...(l.freeCancellationLocal ? { freeCancellationLocal: l.freeCancellationLocal } : {}),
1349
+ // Paid in advance: the review says so, and its button leads to the
1350
+ // payment page rather than to a booking (香格里拉's card, 2026-10-12).
1351
+ ...(l.payment ? { payment: l.payment } : {}),
1232
1352
  day: l.day,
1233
1353
  })),
1234
1354
  subtotal: formatMoney(quote.subtotal),
@@ -1247,7 +1367,7 @@ const tripText = (quote, demo) => {
1247
1367
  if (quote.lines.length === 0)
1248
1368
  return 'The trip is empty.';
1249
1369
  const lines = quote.lines.map((l) => l.kind === 'room'
1250
- ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} ${l.nights === 1 ? 'night' : 'nights'}) — ${formatMoney(l.total)}`
1370
+ ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.hourly ? stayWords(l.stay, l.hourly) : `${stayWords(l.stay)} (${l.nights} ${l.nights === 1 ? 'night' : 'nights'})`} — ${formatMoney(l.total)}`
1251
1371
  : `- ${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)}`);
1252
1372
  return [
1253
1373
  ...lines,
@@ -1456,15 +1576,61 @@ const planGap = (plan) => {
1456
1576
  ? `${parts.join(', ')}; the rest are a plan, not a booking.`
1457
1577
  : `${parts.join(', ')}.`;
1458
1578
  };
1579
+ /** How many people, for a room by the hour asked for without dates. Default 2. */
1580
+ const guestsOf = (args) => Math.max(1, Math.min(MAX_GUESTS, Math.floor(Number(args.guests) || 2)));
1581
+ /**
1582
+ * "3 room types", or - where a group sells several rates on one type - "5 rates
1583
+ * on 2 room types": five rows are not five kinds of room.
1584
+ */
1585
+ const offerCount = (rooms) => {
1586
+ const types = new Set(rooms.map((r) => r.roomType?.id ?? r.id)).size;
1587
+ return types === rooms.length
1588
+ ? `${types} room ${types === 1 ? 'type' : 'types'}`
1589
+ : `${rooms.length} rates on ${types} room ${types === 1 ? 'type' : 'types'}`;
1590
+ };
1591
+ /** What one room costs, as the text says it: nights, or the hours of a room by the hour. */
1592
+ const priceWords = (r) => r.hourly
1593
+ ? `${formatMoney(r.stayTotal)} for ${r.hourly.hours} hours incl. taxes and fees, starting between ${r.hourly.earliestStart} and ${r.hourly.latestStart} today and out by ${r.hourly.latestEnd}`
1594
+ : `${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
1595
+ const LABEL_WORDS = {
1596
+ 'room-only': 'room only',
1597
+ breakfast: 'breakfast',
1598
+ 'breakfast-1': 'breakfast for 1',
1599
+ 'breakfast-2': 'breakfast for 2',
1600
+ 'breakfast-3': 'breakfast for 3',
1601
+ package: 'package',
1602
+ 'flash-sale': 'flash sale',
1603
+ 'night-snack': 'late-night rate',
1604
+ 'early-booking': 'booked-ahead rate',
1605
+ promotion: 'promotion',
1606
+ };
1607
+ /** A rate's labels after its name - " [breakfast for 2, promotion]" - or nothing. */
1608
+ const labelWords = (r) => {
1609
+ const words = [
1610
+ ...(r.labels ?? []).map((l) => LABEL_WORDS[l]),
1611
+ ...(r.payment === 'prepaid' ? ['paid in advance'] : []),
1612
+ ];
1613
+ return words.length ? ` [${words.join(', ')}]` : '';
1614
+ };
1615
+ const PAYMENT_WORDS = {
1616
+ awaiting: 'to be paid on the card page',
1617
+ paid: 'paid in advance by card',
1618
+ lapsed: 'not paid in time - nothing booked or charged',
1619
+ refunding: 'refund in full under way',
1620
+ refunded: 'refunded in full',
1621
+ };
1459
1622
  const bookingLine = (b) => {
1460
1623
  const kinds = b.requests?.kinds ?? [];
1461
1624
  return [
1462
- `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${
1625
+ `${b.confirmationCode} · ${b.hotelName} · ${stayWords(b.stay, b.hourly)} · ${
1463
1626
  // ⚠️ SAID AS WHAT IT IS - see BookingStatus. Neither booked nor failed.
1464
1627
  b.status === 'unconfirmed'
1465
1628
  ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
1466
1629
  : b.status}`,
1467
1630
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
1631
+ b.payment
1632
+ ? ` · ${PAYMENT_WORDS[b.payment.status]}${b.payment.status === 'awaiting' && b.payment.url ? ` by ${b.payment.payByLocal ?? b.payment.payBy}: ${b.payment.url}` : ''}`
1633
+ : '',
1468
1634
  ].join('');
1469
1635
  };
1470
1636
  /**
@@ -1509,6 +1675,13 @@ const dateFlex = (dates, stay) => {
1509
1675
  // the other two are worse, which is a claim about prices that are equal.
1510
1676
  const beats = best?.from && chosen?.from && best.from.minor < chosen.from.minor ? best : undefined;
1511
1677
  const beatsBy = beats?.from?.minor;
1678
+ // The cheapest of the window - the chosen date too - when two or more are
1679
+ // priced in one currency; ties all marked. `best` stays what it was: a date
1680
+ // that beats the guest's own.
1681
+ const priced = dates.filter((d) => d.from);
1682
+ const lowest = priced.length >= 2 && new Set(priced.map((d) => d.from?.currency)).size === 1
1683
+ ? Math.min(...priced.map((d) => d.from?.minor ?? Number.POSITIVE_INFINITY))
1684
+ : undefined;
1512
1685
  const structured = {
1513
1686
  dates: dates.map((d) => ({
1514
1687
  checkIn: d.checkIn,
@@ -1517,6 +1690,7 @@ const dateFlex = (dates, stay) => {
1517
1690
  ...(d.chosen ? { chosen: true } : {}),
1518
1691
  // The chosen date cannot match: `beatsBy` is a price strictly below it.
1519
1692
  ...(beatsBy !== undefined && d.from?.minor === beatsBy ? { best: true } : {}),
1693
+ ...(lowest !== undefined && d.from?.minor === lowest ? { lowest: true } : {}),
1520
1694
  })),
1521
1695
  };
1522
1696
  if (!best?.from)
@@ -1814,6 +1988,7 @@ const filtersOf = (args) => {
1814
1988
  ...(facilities.length ? { facilities } : {}),
1815
1989
  ...(args.freeCancellation === true ? { freeCancellation: true } : {}),
1816
1990
  ...(args.breakfast === true ? { breakfast: true } : {}),
1991
+ ...(args.hourly === true ? { hourly: true } : {}),
1817
1992
  };
1818
1993
  return Object.keys(f).length ? f : undefined;
1819
1994
  };
@@ -1823,6 +1998,7 @@ const filterWords = (f) => [
1823
1998
  ...(f.facilities ?? []).map((k) => FACILITIES.find((x) => x.key === k)?.label.toLowerCase()),
1824
1999
  f.freeCancellation ? 'free cancellation' : '',
1825
2000
  f.breakfast ? 'breakfast included' : '',
2001
+ f.hourly ? 'rooms by the hour today' : '',
1826
2002
  ]
1827
2003
  .filter(Boolean)
1828
2004
  .join(', ');
@@ -2347,8 +2523,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2347
2523
  .join(' '),
2348
2524
  };
2349
2525
  }
2526
+ // ⚠️ THE CHEAPEST, SAID BY US - the card never compares two prices it was
2527
+ // handed as text. Only on named nights, in one currency, with two or more
2528
+ // priced to compare; every hotel that ties is marked (总线程, 2026-10-12).
2529
+ // A room by the hour is priced for its own hours, and is not in the race.
2530
+ const stays = hotels.filter((h) => h.forStay && !h.forStay.hours);
2531
+ const lowest = stays.length >= 2 && new Set(stays.map((h) => h.forStay?.total.currency)).size === 1
2532
+ ? Math.min(...stays.map((h) => h.forStay?.total.minor ?? Number.POSITIVE_INFINITY))
2533
+ : undefined;
2350
2534
  const forCard = hotels.map((h) => ({
2351
2535
  ...h,
2536
+ ...(lowest !== undefined && h.forStay && !h.forStay.hours && h.forStay.total.minor === lowest
2537
+ ? { lowest: true }
2538
+ : {}),
2352
2539
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
2353
2540
  fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
2354
2541
  ...(h.forStay
@@ -2390,11 +2577,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2390
2577
  filtersSaid && filterWords(filtersSaid) ? `Filtered to ${filterWords(filtersSaid)}.` : '',
2391
2578
  // What the prices on a dated list are, in one line - and how many cannot
2392
2579
  // be had at all, so a sold-out hotel is not described as a choice.
2393
- ask.stay
2394
- ? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay)
2395
- ? `; ${hotels.filter((h) => h.noRoomsForStay).length} in this answer have no room for those nights`
2396
- : ''}.`
2397
- : '',
2580
+ ask.filters?.hourly && hotels.some((h) => h.forStay?.hours)
2581
+ ? `By the hour, today: each price is that ${deps.vocab.noun}'s cheapest room by the hour, for its hours, taxes included.`
2582
+ : ask.stay
2583
+ ? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay)
2584
+ ? `; ${hotels.filter((h) => h.noRoomsForStay).length} in this answer have no room for those nights`
2585
+ : ''}.`
2586
+ : '',
2398
2587
  ].filter(Boolean);
2399
2588
  return {
2400
2589
  structured: {
@@ -2437,9 +2626,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2437
2626
  }
2438
2627
  if (name === t.getHotel) {
2439
2628
  const wanted = str(args.hotel);
2440
- const stay = asStay(args);
2441
2629
  const who = identity(deps, ctx, key);
2442
- const detail = await deps.backend.getHotel(wanted, stay, who);
2630
+ // Today's rooms by the hour, on the hotel's own date - see `hourlyRooms`.
2631
+ const byTheHour = args.hourly === true && !!deps.backend.hourlyRooms;
2632
+ const detail = byTheHour
2633
+ ? await deps.backend.hourlyRooms?.(wanted, guestsOf(args), who)
2634
+ : await deps.backend.getHotel(wanted, asStay(args), who);
2635
+ const stay = byTheHour ? detail?.stay : asStay(args);
2443
2636
  if (!detail) {
2444
2637
  const candidates = candidatesFor(deps.backend, wanted);
2445
2638
  return {
@@ -2488,7 +2681,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2488
2681
  // for the two reasons a different arrival cannot fix - the dates are not
2489
2682
  // bookable at all, or no room type here sleeps this party - and offering a
2490
2683
  // week of alternatives to either is noise dressed as help.
2491
- const dates = detail.rooms
2684
+ // Other arrival dates mean nothing to a room sold for today only.
2685
+ const dates = detail.rooms && !byTheHour
2492
2686
  ? await deps.backend.datesAround(h.id, stay, { rooms: roomsWanted, span: 3 }, who)
2493
2687
  : [];
2494
2688
  const flex = dateFlex(dates, stay);
@@ -2562,8 +2756,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2562
2756
  ...flex.structured,
2563
2757
  },
2564
2758
  text: [
2565
- `**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
2566
- ...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 ${freeUntilText(r)}` : r.refundable ? ', the free cancellation window has closed - cancelling now has a fee' : ', non-refundable'}`),
2759
+ byTheHour
2760
+ ? `**${h.name}** · ${enough.length} ${enough.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests.`
2761
+ : `**${h.name}** · ${offerCount(enough)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
2762
+ ...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'}`),
2567
2763
  flex.text,
2568
2764
  deps.vocab.demo ? DEMO_NOTE : '',
2569
2765
  ]
@@ -2840,19 +3036,80 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2840
3036
  text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
2841
3037
  };
2842
3038
  }
3039
+ // A room by the hour: today, from the start the guest gave - checked here,
3040
+ // against the starts the room takes, and never moved to fit (总线程,
3041
+ // 2026-10-12).
3042
+ const who = identity(deps, ctx, key);
3043
+ const startTime = str(args.startTime);
3044
+ const today = (startTime || !asStay(args)
3045
+ ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
3046
+ : undefined);
3047
+ const byTheHour = async () => (await today)?.rooms?.find((r) => r.hourly && (r.id === roomRef || r.name.toLowerCase() === roomRef.toLowerCase()));
3048
+ if (startTime && deps.backend.hourlyRooms) {
3049
+ const hourlyRoom = await byTheHour();
3050
+ if (!hourlyRoom?.hourly) {
3051
+ const sold = ((await today)?.rooms ?? []).filter((r) => r.hourly);
3052
+ return {
3053
+ structured: {
3054
+ view: 'error',
3055
+ ...common(deps, key),
3056
+ error: 'no-such-room',
3057
+ query: roomRef,
3058
+ rooms: sold.map((r) => ({ id: r.id, name: r.name })),
3059
+ },
3060
+ text: sold.length
3061
+ ? `${detail.hotel.name} has no room by the hour by that name today. It has: ${sold.map((r) => `${r.name} (\`${r.id}\`, ${windowWords(r.hourly)})`).join('; ')}.`
3062
+ : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3063
+ };
3064
+ }
3065
+ const bad = startProblem(hourlyRoom.hourly, startTime);
3066
+ if (bad) {
3067
+ return {
3068
+ structured: {
3069
+ view: 'error',
3070
+ ...common(deps, key),
3071
+ error: 'start-time',
3072
+ query: startTime,
3073
+ // The starts it does take. Not `hourly`: that is the flag above.
3074
+ window: hourlyRoom.hourly,
3075
+ },
3076
+ text: bad,
3077
+ };
3078
+ }
3079
+ deps.trips.set(key, {
3080
+ kind: 'room',
3081
+ hotelId,
3082
+ roomId: hourlyRoom.id,
3083
+ stay: {
3084
+ checkIn: hourlyRoom.hourly.date,
3085
+ checkOut: hourlyRoom.hourly.date,
3086
+ guests: guestsOf(args),
3087
+ },
3088
+ rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3089
+ hourly: {
3090
+ start: clockOf(minutesOf(startTime)),
3091
+ hours: hourlyRoom.hourly.hours,
3092
+ },
3093
+ });
3094
+ return currentTrip(deps, key, who);
3095
+ }
2843
3096
  const stay = asStay(args);
2844
3097
  // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
2845
3098
  // connector books someone the wrong nights, and the guest finds out at the
2846
3099
  // desk. The trip stays as it was and the answer asks.
2847
3100
  if (!stay) {
3101
+ const hourlyRoom = await byTheHour();
2848
3102
  return {
2849
3103
  structured: {
2850
3104
  view: 'needs-dates',
2851
3105
  ...common(deps, key),
2852
3106
  hotel: detail.hotel,
2853
3107
  room: roomRef,
3108
+ ...(hourlyRoom?.hourly ? { window: hourlyRoom.hourly } : {}),
2854
3109
  },
2855
- text: 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
3110
+ text: hourlyRoom?.hourly
3111
+ ? `That is a room by the hour, sold for today: it takes \`startTime\` - ${windowWords(hourlyRoom.hourly)}.`
3112
+ : 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
2856
3113
  };
2857
3114
  }
2858
3115
  // The backend owns the calendar - it knows each hotel's own timezone, and a
@@ -2880,6 +3137,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2880
3137
  error: 'no-such-room',
2881
3138
  query: roomRef,
2882
3139
  rooms: dated.rooms.map((r) => ({ id: r.id, name: r.name })),
3140
+ // The hotel and the nights, so the card can take the guest straight
3141
+ // back to its rooms rather than to the start (round-2 jury).
3142
+ hotel: { id: detail.hotel.id, name: detail.hotel.name },
3143
+ stay,
2883
3144
  },
2884
3145
  text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
2885
3146
  };
@@ -3035,16 +3296,20 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3035
3296
  const several = booked.length > 1;
3036
3297
  return {
3037
3298
  structured: {
3299
+ // ⚠️ 'booking' EVEN WHEN IT IS AWAITING PAYMENT: the card draws a view
3300
+ // only once it knows it, and the card-contract test holds every view
3301
+ // here to that. 香格里拉's card turns this into its own 'payment' step
3302
+ // (booking.status 'awaiting-payment', booking.payment) - and moves the
3303
+ // view with it.
3038
3304
  view: 'booking',
3039
3305
  ...common(deps, key),
3040
- booking: { ...first.booking, quote: quoteForCard(first.booking.quote) },
3306
+ booking: bookingForCard(first.booking),
3041
3307
  // ⚠️ ONE PER STAY - see `requestBooking`. The card draws each with its
3042
3308
  // own code; `booking` stays the first, for a host that reads one.
3043
3309
  ...(several
3044
3310
  ? {
3045
3311
  bookings: booked.map(({ booking, asked }) => ({
3046
- ...booking,
3047
- quote: quoteForCard(booking.quote),
3312
+ ...bookingForCard(booking),
3048
3313
  total: formatMoney(booking.quote.total),
3049
3314
  ...(asked.length ? { requested: asked } : {}),
3050
3315
  // Each its own warning colour - it rode on the first alone.
@@ -3068,7 +3333,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3068
3333
  ? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${holderOf(deps, first.booking.supplier).caps.changes ? 'changed or cancelled' : 'cancelled'} on its own.`
3069
3334
  : '',
3070
3335
  ...booked.flatMap(({ booking, asked }) => [
3071
- `**${booking.confirmationCode}** · ${booking.hotelName} · ${booking.stay.checkIn} to ${booking.stay.checkOut}`,
3336
+ `**${booking.confirmationCode}** · ${booking.hotelName} · ${stayWords(booking.stay, booking.hourly)}`,
3072
3337
  bookedLine(deps, booking),
3073
3338
  ...asked,
3074
3339
  ]),
@@ -3133,7 +3398,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3133
3398
  structured: {
3134
3399
  view: 'bookings',
3135
3400
  ...common(deps, key),
3136
- bookings: bookings.map((b) => ({ ...b, quote: quoteForCard(b.quote) })),
3401
+ bookings: bookings.map(bookingForCard),
3137
3402
  count: bookings.length,
3138
3403
  // What was looked for, so a card can say THAT did not match rather
3139
3404
  // than "nothing in this conversation".
@@ -3267,7 +3532,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3267
3532
  structured: {
3268
3533
  view: 'changed',
3269
3534
  ...common(deps, key),
3270
- booking: { ...changed, quote: quoteForCard(changed.quote) },
3535
+ booking: bookingForCard(changed),
3271
3536
  hotelName: preview.hotelName,
3272
3537
  from: stayOf(preview.from),
3273
3538
  ...(preview.to ? { to: stayOf(preview.to) } : {}),
@@ -3368,19 +3633,22 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3368
3633
  structured: {
3369
3634
  view: 'cancelled',
3370
3635
  ...common(deps, key),
3371
- booking: { ...cancelled, quote: quoteForCard(cancelled.quote) },
3636
+ booking: bookingForCard(cancelled),
3372
3637
  fee: formatMoney(charged),
3373
3638
  rule: preview.rule,
3374
3639
  cancellable: false,
3375
3640
  },
3376
3641
  text: [
3377
- h.sandbox
3378
- ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
3379
- `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
3380
- : h.demo
3381
- ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
3382
- `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
3383
- : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
3642
+ cancelled.payment
3643
+ ? // Paid here in advance: what becomes of the money is the answer.
3644
+ `${cancelled.confirmationCode} is ${cancelled.status === 'expired' ? 'called off before it was paid' : 'cancelled'}. ${paidLine(deps, cancelled)}`
3645
+ : h.sandbox
3646
+ ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
3647
+ `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
3648
+ : h.demo
3649
+ ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
3650
+ `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
3651
+ : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
3384
3652
  demoBookingNote(deps, cancelled.supplier),
3385
3653
  deps.vocab.demo ? DEMO_NOTE : '',
3386
3654
  ]