@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.
- package/dist/backend.d.ts +19 -0
- package/dist/backend.d.ts.map +1 -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/config.d.ts +45 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +7 -1
- package/dist/demo-hotels.js.map +1 -1
- package/dist/hourly.d.ts +31 -0
- package/dist/hourly.d.ts.map +1 -0
- package/dist/hourly.js +61 -0
- package/dist/hourly.js.map +1 -0
- package/dist/huazhu/backend.d.ts +13 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +712 -39
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +37 -1
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/huazhu/check.js +2 -2
- package/dist/huazhu/check.js.map +1 -1
- package/dist/huazhu/client.d.ts +3 -3
- package/dist/huazhu/client.d.ts.map +1 -1
- package/dist/huazhu/client.js +4 -4
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/english.d.ts.map +1 -1
- package/dist/huazhu/english.js +15 -0
- package/dist/huazhu/english.js.map +1 -1
- package/dist/huazhu/fake.d.ts +3 -1
- package/dist/huazhu/fake.d.ts.map +1 -1
- package/dist/huazhu/fake.js +69 -7
- package/dist/huazhu/fake.js.map +1 -1
- package/dist/huazhu/labels.d.ts +9 -0
- package/dist/huazhu/labels.d.ts.map +1 -0
- package/dist/huazhu/labels.js +28 -0
- package/dist/huazhu/labels.js.map +1 -0
- package/dist/huazhu/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +26 -0
- package/dist/huazhu/make.js.map +1 -1
- package/dist/huazhu/sandbox-photos.d.ts +7 -0
- package/dist/huazhu/sandbox-photos.d.ts.map +1 -1
- package/dist/huazhu/sandbox-photos.js +7 -0
- package/dist/huazhu/sandbox-photos.js.map +1 -1
- package/dist/huazhu/sandbox.d.ts +2 -0
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +125 -6
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +46 -3
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +105 -12
- package/dist/huazhu/sellable.js.map +1 -1
- package/dist/huazhu/wire.d.ts +11 -0
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +19 -8
- package/dist/legal.js.map +1 -1
- package/dist/payments/checkout.d.ts +71 -0
- package/dist/payments/checkout.d.ts.map +1 -0
- package/dist/payments/checkout.js +133 -0
- package/dist/payments/checkout.js.map +1 -0
- package/dist/payments/fake-gateway.d.ts +43 -0
- package/dist/payments/fake-gateway.d.ts.map +1 -0
- package/dist/payments/fake-gateway.js +135 -0
- package/dist/payments/fake-gateway.js.map +1 -0
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +8 -4
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -4
- package/dist/server.js.map +1 -1
- package/dist/supplier.d.ts +2 -0
- package/dist/supplier.d.ts.map +1 -1
- package/dist/supplier.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +7 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +15 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +298 -30
- package/dist/tools.js.map +1 -1
- package/dist/trip.d.ts +6 -0
- package/dist/trip.d.ts.map +1 -1
- package/dist/trip.js +17 -3
- package/dist/trip.js.map +1 -1
- package/dist/types.d.ts +109 -1
- package/dist/types.d.ts.map +1 -1
- 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.
|
|
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
|
|
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
|
|
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.
|
|
2394
|
-
? `
|
|
2395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2566
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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:
|
|
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:
|
|
3636
|
+
booking: bookingForCard(cancelled),
|
|
3372
3637
|
fee: formatMoney(charged),
|
|
3373
3638
|
rule: preview.rule,
|
|
3374
3639
|
cancellable: false,
|
|
3375
3640
|
},
|
|
3376
3641
|
text: [
|
|
3377
|
-
|
|
3378
|
-
? //
|
|
3379
|
-
`${cancelled.confirmationCode} is
|
|
3380
|
-
: h.
|
|
3381
|
-
? //
|
|
3382
|
-
`${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a
|
|
3383
|
-
:
|
|
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
|
]
|