@curless/hotel-storefront 0.45.2 → 0.46.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/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +281 -19
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +46 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +44 -11
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +9 -5
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/room-filters.d.ts +76 -0
- package/dist/room-filters.d.ts.map +1 -0
- package/dist/room-filters.js +191 -0
- package/dist/room-filters.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/supplier.d.ts +1 -0
- package/dist/supplier.d.ts.map +1 -1
- package/dist/supplier.js +4 -1
- package/dist/supplier.js.map +1 -1
- package/dist/tools.d.ts +20 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +158 -9
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +36 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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 \`
|
|
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,
|
|
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\` and \`freeCancellation\` 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,19 @@ 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
|
+
},
|
|
352
371
|
},
|
|
353
372
|
required: ['hotel'],
|
|
354
373
|
},
|
|
@@ -1029,6 +1048,14 @@ const HOTEL_PAGE = {
|
|
|
1029
1048
|
policies: ARR,
|
|
1030
1049
|
/** Arrivals a few days either side, each re-quoted for the same nights - what the date picker prices its days from. */
|
|
1031
1050
|
dates: ARR,
|
|
1051
|
+
/**
|
|
1052
|
+
* The room filters this hotel's rooms offer, each with how many rooms (rates)
|
|
1053
|
+
* it keeps - `{ key: 'breakfast' | 'king' | 'twin' | 'freeCancellation', count }`.
|
|
1054
|
+
* Only a kind at least one room has.
|
|
1055
|
+
*/
|
|
1056
|
+
roomFilters: ARR,
|
|
1057
|
+
/** The filters the call asked for, as the same keys - the card opens with these selected. */
|
|
1058
|
+
activeFilters: STRS,
|
|
1032
1059
|
};
|
|
1033
1060
|
export const buildOutputSchemas = (n) => ({
|
|
1034
1061
|
[n.listHotels]: {
|
|
@@ -1965,6 +1992,107 @@ const labelWords = (r) => {
|
|
|
1965
1992
|
];
|
|
1966
1993
|
return words.length ? ` [${words.join(', ')}]` : '';
|
|
1967
1994
|
};
|
|
1995
|
+
/** What a rate says about cancelling, as the text puts it. */
|
|
1996
|
+
const cancelWords = (r) => r.freeCancellationUntil
|
|
1997
|
+
? `free to cancel until ${freeUntilText(r)}`
|
|
1998
|
+
: r.refundable
|
|
1999
|
+
? 'the free cancellation window has closed - cancelling now has a fee'
|
|
2000
|
+
: 'non-refundable';
|
|
2001
|
+
/**
|
|
2002
|
+
* What a room is, in a parenthesis: its beds, how many it sleeps and - for one
|
|
2003
|
+
* rate, not a type whose rates differ - breakfast, unless the rate's labels
|
|
2004
|
+
* already say it.
|
|
2005
|
+
*/
|
|
2006
|
+
const roomFactsWords = (r, rate = false) => {
|
|
2007
|
+
const facts = [
|
|
2008
|
+
r.beds,
|
|
2009
|
+
`sleeps ${r.sleeps}`,
|
|
2010
|
+
rate && facetsOf(r).breakfast && !(r.labels ?? []).some((l) => l.startsWith('breakfast'))
|
|
2011
|
+
? 'breakfast included'
|
|
2012
|
+
: '',
|
|
2013
|
+
]
|
|
2014
|
+
.filter(Boolean)
|
|
2015
|
+
.join(', ');
|
|
2016
|
+
return ` (${facts})`;
|
|
2017
|
+
};
|
|
2018
|
+
/** The cheapest price a rate quotes, as the text says it: "from" a night, or the hours of a block. */
|
|
2019
|
+
const fromWords = (r) => r.hourly
|
|
2020
|
+
? `${formatMoney(r.stayTotal)} for ${r.hourly.hours} hours incl. taxes and fees`
|
|
2021
|
+
: `${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
|
|
2022
|
+
/**
|
|
2023
|
+
* A hotel's rooms for the nights, one line per ROOM TYPE.
|
|
2024
|
+
*
|
|
2025
|
+
* ⚠️ A TYPE IS ONE LINE, NOT ONE LINE PER RATE CODE. A Huazhu hotel sells a
|
|
2026
|
+
* dozen rates on three types, and every code in one long run buried the types
|
|
2027
|
+
* (robin, 2026-10-23, after Qunar's page). A type with one rate is that rate,
|
|
2028
|
+
* in full; a type with several says how many, its lowest price, which rate that
|
|
2029
|
+
* is - the name `room` takes - and what the rest have, in a few words.
|
|
2030
|
+
* Another rate is reached by narrowing: `bed`, `breakfast`, `freeCancellation`.
|
|
2031
|
+
*/
|
|
2032
|
+
const roomTypeLines = (rooms, roomsWanted,
|
|
2033
|
+
/** The terms already applied: what they say is true of every rate here, so it is not counted again. */
|
|
2034
|
+
terms) => {
|
|
2035
|
+
const forRooms = (r) => roomsWanted > 1
|
|
2036
|
+
? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)`
|
|
2037
|
+
: '';
|
|
2038
|
+
return byRoomType(rooms).map((type) => {
|
|
2039
|
+
const cheapest = type.rooms.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a));
|
|
2040
|
+
const n = type.rooms.length;
|
|
2041
|
+
if (n === 1)
|
|
2042
|
+
return `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
|
|
2043
|
+
const some = (k, all, part) => k === n ? all : k > 0 ? `${k} of ${n} ${part}` : '';
|
|
2044
|
+
const also = [
|
|
2045
|
+
terms?.breakfast
|
|
2046
|
+
? ''
|
|
2047
|
+
: some(type.rooms.filter((r) => facetsOf(r).breakfast).length, 'all include breakfast', 'include breakfast'),
|
|
2048
|
+
terms?.freeCancellation
|
|
2049
|
+
? ''
|
|
2050
|
+
: some(type.rooms.filter((r) => facetsOf(r).freeCancellation).length, 'all are free to cancel', 'are free to cancel'),
|
|
2051
|
+
].filter(Boolean);
|
|
2052
|
+
return `- ${type.name}${roomFactsWords(cheapest)} — ${n} rates from ${fromWords(cheapest)}${forRooms(cheapest)}. Cheapest: "${cheapest.name}"${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`;
|
|
2053
|
+
});
|
|
2054
|
+
};
|
|
2055
|
+
/**
|
|
2056
|
+
* What a hotel's rooms look like to the assistant for the nights: one line per
|
|
2057
|
+
* type, and with filters only the rates that meet ALL of them. None meeting
|
|
2058
|
+
* them is said plainly - which filters were asked, what each leaves on its own
|
|
2059
|
+
* - and that the full list is there without them.
|
|
2060
|
+
*/
|
|
2061
|
+
const roomsText = (hotelName,
|
|
2062
|
+
/** "3 room types for 2026-11-04 to 2026-11-06, 2 guests" - what the rooms are, counted. */
|
|
2063
|
+
counted,
|
|
2064
|
+
/** "2026-11-04 to 2026-11-06" - when, for the sentence that says none fit. */
|
|
2065
|
+
when, enough, roomsWanted, terms) => {
|
|
2066
|
+
// A type with several rates is priced from its cheapest: say how to reach another.
|
|
2067
|
+
const lines = (rs) => [
|
|
2068
|
+
...roomTypeLines(rs, roomsWanted, terms),
|
|
2069
|
+
...(byRoomType(rs).some((t) => t.rooms.length > 1)
|
|
2070
|
+
? [
|
|
2071
|
+
'A type with several rates is priced from its cheapest one; bed, breakfast and freeCancellation narrow to the others (the card lists every rate).',
|
|
2072
|
+
]
|
|
2073
|
+
: []),
|
|
2074
|
+
];
|
|
2075
|
+
if (!terms)
|
|
2076
|
+
return [`**${hotelName}** · ${counted(enough)}.`, ...lines(enough)];
|
|
2077
|
+
const asked = termWords(terms);
|
|
2078
|
+
const several = activeKeysOf(terms).length > 1;
|
|
2079
|
+
const rates = (n) => `${n} ${n === 1 ? 'rate' : 'rates'}`;
|
|
2080
|
+
const matching = enough.filter((r) => matchesTerms(r, terms));
|
|
2081
|
+
if (matching.length === 0) {
|
|
2082
|
+
const alone = several
|
|
2083
|
+
? ` On its own, ${activeKeysOf(terms)
|
|
2084
|
+
.map((k) => `${keyWords(k)} fits ${enough.filter((r) => facetsOf(r)[k]).length}`)
|
|
2085
|
+
.join(', ')}.`
|
|
2086
|
+
: '';
|
|
2087
|
+
return [
|
|
2088
|
+
`**${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'}.`,
|
|
2089
|
+
];
|
|
2090
|
+
}
|
|
2091
|
+
return [
|
|
2092
|
+
`**${hotelName}** · ${counted(matching)}, with ${asked} (${matching.length} of ${rates(enough.length)}).`,
|
|
2093
|
+
...lines(matching),
|
|
2094
|
+
];
|
|
2095
|
+
};
|
|
1968
2096
|
const PAYMENT_WORDS = {
|
|
1969
2097
|
awaiting: 'to be paid on the card page',
|
|
1970
2098
|
issued: 'confirmed on UnionPay - the hotel is being asked to book it',
|
|
@@ -2381,6 +2509,7 @@ const filtersOf = (args) => {
|
|
|
2381
2509
|
...(facilities.length ? { facilities } : {}),
|
|
2382
2510
|
...(args.freeCancellation === true ? { freeCancellation: true } : {}),
|
|
2383
2511
|
...(args.breakfast === true ? { breakfast: true } : {}),
|
|
2512
|
+
...(args.bed === 'king' || args.bed === 'twin' ? { bed: args.bed } : {}),
|
|
2384
2513
|
...(args.hourly === true ? { hourly: true } : {}),
|
|
2385
2514
|
};
|
|
2386
2515
|
return Object.keys(f).length ? f : undefined;
|
|
@@ -2391,6 +2520,7 @@ const filterWords = (f) => [
|
|
|
2391
2520
|
...(f.facilities ?? []).map((k) => FACILITIES.find((x) => x.key === k)?.label.toLowerCase()),
|
|
2392
2521
|
f.freeCancellation ? 'free cancellation' : '',
|
|
2393
2522
|
f.breakfast ? 'breakfast included' : '',
|
|
2523
|
+
f.bed === 'king' ? 'king beds' : f.bed === 'twin' ? 'twin beds' : '',
|
|
2394
2524
|
f.hourly ? 'rooms by the hour today' : '',
|
|
2395
2525
|
]
|
|
2396
2526
|
.filter(Boolean)
|
|
@@ -2401,6 +2531,7 @@ const emptiedBy = (f, before, nouns) => {
|
|
|
2401
2531
|
(f.facilities?.length ?? 0) +
|
|
2402
2532
|
(f.freeCancellation ? 1 : 0) +
|
|
2403
2533
|
(f.breakfast ? 1 : 0) +
|
|
2534
|
+
(f.bed ? 1 : 0) +
|
|
2404
2535
|
// By the hour is a term like the others: alone, it is said alone - not
|
|
2405
2536
|
// "has rooms by the hour today together" (huazhu, 2026-10-13).
|
|
2406
2537
|
(f.hourly ? 1 : 0);
|
|
@@ -3022,7 +3153,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3022
3153
|
const applied = (wantedPrice && !oneCurrency) || answer.ignored?.sort ? 'group' : sort;
|
|
3023
3154
|
// Room terms the backend could not apply are not "filtered to".
|
|
3024
3155
|
const filtersSaid = asked.filters && answer.ignored?.roomTerms
|
|
3025
|
-
? { ...asked.filters, freeCancellation: undefined, breakfast: undefined }
|
|
3156
|
+
? { ...asked.filters, freeCancellation: undefined, breakfast: undefined, bed: undefined }
|
|
3026
3157
|
: asked.filters;
|
|
3027
3158
|
const priceNotes = [
|
|
3028
3159
|
// What the backend was asked and did not do, in its own words.
|
|
@@ -3125,7 +3256,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3125
3256
|
};
|
|
3126
3257
|
if (!stay) {
|
|
3127
3258
|
return {
|
|
3128
|
-
|
|
3259
|
+
// ⚠️ THE FILTERS RIDE ALONG WITHOUT DATES: the card prices the dates
|
|
3260
|
+
// itself and draws its chips from this answer, so a model that asked
|
|
3261
|
+
// for breakfast before any night was named still has it asked.
|
|
3262
|
+
structured: {
|
|
3263
|
+
view: 'hotel',
|
|
3264
|
+
...base,
|
|
3265
|
+
needsDates: true,
|
|
3266
|
+
activeFilters: activeKeysOf(roomTermsOf(args)),
|
|
3267
|
+
},
|
|
3129
3268
|
text: [
|
|
3130
3269
|
`**${h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
|
|
3131
3270
|
description.fenced,
|
|
@@ -3185,8 +3324,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3185
3324
|
.join(' '),
|
|
3186
3325
|
};
|
|
3187
3326
|
}
|
|
3327
|
+
// ⚠️ THE CARD KEEPS EVERY ROOM, THE TEXT KEEPS THE ONES THAT MATCH. The
|
|
3328
|
+
// filters narrow what the assistant is told; the card is sent all of them
|
|
3329
|
+
// with what is true of each (`facets`), so a chip switched on or off on the
|
|
3330
|
+
// card needs no call to the server.
|
|
3331
|
+
const terms = roomTermsOf(args);
|
|
3188
3332
|
const rooms = enough.map((r) => ({
|
|
3189
3333
|
...r,
|
|
3334
|
+
facets: facetsOf(r),
|
|
3190
3335
|
nightly: formatMoney(r.nightly),
|
|
3191
3336
|
stayTotal: formatMoney(r.stayTotal),
|
|
3192
3337
|
taxesAndFees: formatMoney(r.taxesAndFees),
|
|
@@ -3219,14 +3364,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3219
3364
|
stay,
|
|
3220
3365
|
rooms,
|
|
3221
3366
|
rooms_wanted: roomsWanted,
|
|
3367
|
+
roomFilters: optionsOf(enough),
|
|
3368
|
+
activeFilters: activeKeysOf(terms),
|
|
3222
3369
|
...flex.structured,
|
|
3223
3370
|
},
|
|
3224
3371
|
text: [
|
|
3225
|
-
byTheHour
|
|
3226
|
-
?
|
|
3227
|
-
:
|
|
3228
|
-
|
|
3229
|
-
flex.text
|
|
3372
|
+
...roomsText(h.name, byTheHour
|
|
3373
|
+
? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
|
|
3374
|
+
: (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),
|
|
3375
|
+
// The other arrivals are priced on the cheapest room of any kind.
|
|
3376
|
+
terms && flex.text
|
|
3377
|
+
? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
|
|
3378
|
+
: flex.text,
|
|
3230
3379
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
3231
3380
|
]
|
|
3232
3381
|
.filter(Boolean)
|