@curless/hotel-storefront 0.27.8 → 0.27.10
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/README.md +1 -1
- package/dist/aggregate/hotels.d.ts +45 -0
- package/dist/aggregate/hotels.d.ts.map +1 -0
- package/dist/aggregate/hotels.js +633 -0
- package/dist/aggregate/hotels.js.map +1 -0
- package/dist/aggregate/ids.d.ts +36 -0
- package/dist/aggregate/ids.d.ts.map +1 -0
- package/dist/aggregate/ids.js +64 -0
- package/dist/aggregate/ids.js.map +1 -0
- package/dist/aggregate/make.d.ts +12 -0
- package/dist/aggregate/make.d.ts.map +1 -0
- package/dist/aggregate/make.js +96 -0
- package/dist/aggregate/make.js.map +1 -0
- package/dist/aggregate/paging.d.ts +35 -0
- package/dist/aggregate/paging.d.ts.map +1 -0
- package/dist/aggregate/paging.js +94 -0
- package/dist/aggregate/paging.js.map +1 -0
- package/dist/backend.d.ts +41 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +2 -2
- package/dist/card.generated.d.ts.map +1 -1
- package/dist/card.generated.js +3 -3
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +31 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/demo-hotels.d.ts +6 -0
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +16 -6
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +2 -0
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/make.d.ts +4 -0
- package/dist/huazhu/make.d.ts.map +1 -0
- package/dist/huazhu/make.js +105 -0
- package/dist/huazhu/make.js.map +1 -0
- package/dist/huazhu/sandbox-photos.d.ts +25 -0
- package/dist/huazhu/sandbox-photos.d.ts.map +1 -0
- package/dist/huazhu/sandbox-photos.js +514 -0
- package/dist/huazhu/sandbox-photos.js.map +1 -0
- package/dist/huazhu/sandbox.d.ts +4 -2
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +763 -45
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/legal.d.ts +1 -0
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +188 -66
- package/dist/legal.js.map +1 -1
- package/dist/lib.d.ts +1 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/server.d.ts +10 -22
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +40 -153
- package/dist/server.js.map +1 -1
- package/dist/state.d.ts +8 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +18 -1
- package/dist/state.js.map +1 -1
- package/dist/supplier.d.ts +50 -0
- package/dist/supplier.d.ts.map +1 -0
- package/dist/supplier.js +57 -0
- package/dist/supplier.js.map +1 -0
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +37 -21
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +29 -23
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +197 -67
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +20 -0
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +22 -1
- package/dist/vocab.js.map +1 -1
- package/package.json +11 -11
package/dist/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
|
|
|
6
6
|
import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
|
|
7
7
|
import { regionOf } from './regions.js';
|
|
8
8
|
import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, TRIP_TTL_MS, clampCount, shownOf, tripKey, } from './trip.js';
|
|
9
|
-
import { DEMO_NOTE } from './vocab.js';
|
|
9
|
+
import { DEMO_NOTE, branded, catalogue, upFirst } from './vocab.js';
|
|
10
10
|
// The eleven things a guest can do: find hotels, look at one on dates, see what
|
|
11
11
|
// else it offers, build a trip, ask for the rooms, and deal with a booking they
|
|
12
12
|
// already have.
|
|
@@ -151,7 +151,8 @@ const forCapabilities = (tools, n, v, caps) => {
|
|
|
151
151
|
}
|
|
152
152
|
if (!caps.members)
|
|
153
153
|
out = { ...out, inputSchema: without(out.inputSchema, 'rememberRequests') };
|
|
154
|
-
|
|
154
|
+
// A market's suppliers book differently - its own description says so.
|
|
155
|
+
if (caps.booking === 'pay-at-hotel' && !v.market) {
|
|
155
156
|
out = {
|
|
156
157
|
...out,
|
|
157
158
|
// ⚠️ A SANDBOX BOOKS NOTHING REAL, and its description says so
|
|
@@ -182,8 +183,8 @@ const allTools = (n, v) => {
|
|
|
182
183
|
return [
|
|
183
184
|
{
|
|
184
185
|
name: n.listHotels,
|
|
185
|
-
description: `${v
|
|
186
|
-
annotations: reads(`Browse ${v
|
|
186
|
+
description: `${upFirst(catalogue(v))}, shown as a card. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\` and \`breakfast\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
|
|
187
|
+
annotations: reads(`Browse ${catalogue(v)}`, open),
|
|
187
188
|
inputSchema: {
|
|
188
189
|
type: 'object',
|
|
189
190
|
properties: {
|
|
@@ -223,6 +224,14 @@ const allTools = (n, v) => {
|
|
|
223
224
|
type: 'string',
|
|
224
225
|
description: "One of the group's brands, as the list's brand counts name it",
|
|
225
226
|
},
|
|
227
|
+
...(v.market
|
|
228
|
+
? {
|
|
229
|
+
group: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
description: "One hotel group's hotels only, by the group's name",
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: {}),
|
|
226
235
|
facilities: {
|
|
227
236
|
type: 'array',
|
|
228
237
|
items: { type: 'string', enum: FACILITIES.map((x) => x.key) },
|
|
@@ -246,7 +255,7 @@ const allTools = (n, v) => {
|
|
|
246
255
|
{
|
|
247
256
|
name: n.getHotel,
|
|
248
257
|
description: `One ${v.noun} in full: description, amenities, address, and check-in and check-out times where the ${v.noun} publishes them. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known. The ${v.noun} is drawn as a card for the guest to look at.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
|
|
249
|
-
annotations: reads(`View one ${v
|
|
258
|
+
annotations: reads(`View one ${catalogue(v, true)}`, open),
|
|
250
259
|
inputSchema: {
|
|
251
260
|
type: 'object',
|
|
252
261
|
properties: {
|
|
@@ -274,7 +283,7 @@ const allTools = (n, v) => {
|
|
|
274
283
|
// reference apps do the same split: search without a card, present with
|
|
275
284
|
// one.
|
|
276
285
|
description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. The ids are the ones a comparison and a plan take.`,
|
|
277
|
-
annotations: reads(`Look up ${v
|
|
286
|
+
annotations: reads(`Look up ${catalogue(v)}`, open),
|
|
278
287
|
inputSchema: {
|
|
279
288
|
type: 'object',
|
|
280
289
|
properties: {
|
|
@@ -300,7 +309,7 @@ const allTools = (n, v) => {
|
|
|
300
309
|
{
|
|
301
310
|
name: n.listExtras,
|
|
302
311
|
description: `What one ${v.noun} offers beside the room: airport transfers, dinners, guided experiences, spa and family add-ons, each with its price and whether it is charged per stay, per person or per night, drawn as a card for the guest to look at.`,
|
|
303
|
-
annotations: reads(`${v.brand} add-ons`, open),
|
|
312
|
+
annotations: reads(v.market ? `Add-ons on ${v.brand}` : `${v.brand} add-ons`, open),
|
|
304
313
|
inputSchema: {
|
|
305
314
|
type: 'object',
|
|
306
315
|
properties: {
|
|
@@ -367,10 +376,15 @@ const allTools = (n, v) => {
|
|
|
367
376
|
// ⚠️ A DEMONSTRATION BOOKING IS SAID AS ONE (2026-10-05 audit, P2):
|
|
368
377
|
// "asks Shangri-La to hold the rooms" read as a real hold, and a guest
|
|
369
378
|
// could take the code to the real hotel.
|
|
370
|
-
description: v.
|
|
371
|
-
?
|
|
372
|
-
|
|
373
|
-
|
|
379
|
+
description: v.market
|
|
380
|
+
? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
|
|
381
|
+
// makes is the group's: a hold, a test booking, or a reservation paid
|
|
382
|
+
// at the hotel. The answer says which, stay by stay.
|
|
383
|
+
`Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a demonstration hold that lapses on its own, a test booking in a simulated system, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
|
|
384
|
+
: v.demo
|
|
385
|
+
? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
|
|
386
|
+
: `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
|
|
387
|
+
annotations: books(v.market ? `Request a booking on ${v.brand}` : `Request a ${v.brand} booking`, open),
|
|
374
388
|
inputSchema: {
|
|
375
389
|
type: 'object',
|
|
376
390
|
properties: {
|
|
@@ -397,6 +411,16 @@ const allTools = (n, v) => {
|
|
|
397
411
|
type: 'boolean',
|
|
398
412
|
description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet or connecting room; an accessible room is asked for on each stay and is not kept',
|
|
399
413
|
},
|
|
414
|
+
// A booking is made with one group at a time; a trip holding two
|
|
415
|
+
// groups' hotels is booked group by group (Marketplace, §6-1).
|
|
416
|
+
...(v.market
|
|
417
|
+
? {
|
|
418
|
+
group: {
|
|
419
|
+
type: 'string',
|
|
420
|
+
description: "The hotel group to book now, by name, when the trip holds more than one group's hotels. Its stays are booked and the others stay in the trip",
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
: {}),
|
|
400
424
|
},
|
|
401
425
|
required: ['name', 'email'],
|
|
402
426
|
},
|
|
@@ -557,7 +581,7 @@ const allTools = (n, v) => {
|
|
|
557
581
|
// model that could send a price could one day send the wrong one, onto a
|
|
558
582
|
// card that looks official.
|
|
559
583
|
description: `Puts two to four ${v.nounPlural} side by side as one card. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
|
|
560
|
-
annotations: reads(`Compare ${v
|
|
584
|
+
annotations: reads(`Compare ${catalogue(v)}`, open),
|
|
561
585
|
inputSchema: {
|
|
562
586
|
type: 'object',
|
|
563
587
|
properties: {
|
|
@@ -590,7 +614,7 @@ const allTools = (n, v) => {
|
|
|
590
614
|
{
|
|
591
615
|
name: n.itinerary,
|
|
592
616
|
description: `A day-by-day plan as one card: each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
|
|
593
|
-
annotations: reads(`Lay out a ${v.brand} itinerary`, open),
|
|
617
|
+
annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
|
|
594
618
|
inputSchema: {
|
|
595
619
|
type: 'object',
|
|
596
620
|
properties: {
|
|
@@ -680,7 +704,7 @@ const allTools = (n, v) => {
|
|
|
680
704
|
{
|
|
681
705
|
name: n.policies,
|
|
682
706
|
description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
|
|
683
|
-
annotations: reads(`${v.brand} policies`, open),
|
|
707
|
+
annotations: reads(v.market ? `${upFirst(v.noun)} policies on ${v.brand}` : `${v.brand} policies`, open),
|
|
684
708
|
inputSchema: {
|
|
685
709
|
type: 'object',
|
|
686
710
|
properties: {
|
|
@@ -1046,6 +1070,40 @@ const memberBadge = (deps, key) => {
|
|
|
1046
1070
|
}
|
|
1047
1071
|
: { signedIn: false };
|
|
1048
1072
|
};
|
|
1073
|
+
/** A market's group by its id or its name, as a guest or a model would say it. */
|
|
1074
|
+
const groupNamed = (deps, said) => {
|
|
1075
|
+
const want = said.trim().toLowerCase();
|
|
1076
|
+
return deps.backend
|
|
1077
|
+
.suppliers?.()
|
|
1078
|
+
.find((x) => x.id.toLowerCase() === want || x.name.toLowerCase() === want);
|
|
1079
|
+
};
|
|
1080
|
+
/** The same trip line at a count of nothing - which takes it out (`TripStore.set`). */
|
|
1081
|
+
const unset = (l) => l.kind === 'extra' ? { ...l, quantity: 0 } : { ...l, rooms: 0 };
|
|
1082
|
+
/**
|
|
1083
|
+
* On a group's own connector, the connector itself. In a market, the supplier
|
|
1084
|
+
* named on the booking or the hotel.
|
|
1085
|
+
*
|
|
1086
|
+
* ⚠️ NEVER THE MARKET. "Asks Otaless to hold the rooms" or "Otaless's own
|
|
1087
|
+
* reservation" would send a guest to a desk that has never heard of Otaless,
|
|
1088
|
+
* and a market holding a demonstration beside a group's real system must not
|
|
1089
|
+
* call either by the other's terms (robin, 2026-10-07).
|
|
1090
|
+
*/
|
|
1091
|
+
const holderOf = (deps, supplier) => {
|
|
1092
|
+
const s = supplier ? deps.backend.suppliers?.().find((x) => x.id === supplier) : undefined;
|
|
1093
|
+
if (s)
|
|
1094
|
+
return {
|
|
1095
|
+
name: s.name,
|
|
1096
|
+
caps: s.capabilities,
|
|
1097
|
+
demo: s.mode !== 'live',
|
|
1098
|
+
sandbox: s.mode === 'sandbox',
|
|
1099
|
+
};
|
|
1100
|
+
return {
|
|
1101
|
+
name: deps.vocab.brand,
|
|
1102
|
+
caps: deps.backend.capabilities ?? FULL_CAPABILITIES,
|
|
1103
|
+
demo: deps.vocab.demo,
|
|
1104
|
+
sandbox: !!deps.vocab.sandbox,
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1049
1107
|
/**
|
|
1050
1108
|
* What was made, in one sentence - which depends on what asking MAKES here.
|
|
1051
1109
|
*
|
|
@@ -1058,47 +1116,56 @@ const memberBadge = (deps, key) => {
|
|
|
1058
1116
|
*/
|
|
1059
1117
|
const bookedLine = (deps, b) => {
|
|
1060
1118
|
const total = formatMoney(b.quote.total);
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1119
|
+
const h = holderOf(deps, b.supplier);
|
|
1120
|
+
if (h.caps.booking === 'pay-at-hotel') {
|
|
1121
|
+
if (h.sandbox)
|
|
1122
|
+
return `${total} in the simulated system. **This is a test booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
|
|
1064
1123
|
return b.status === 'requested'
|
|
1065
|
-
? `${total} all in, to be paid at the hotel. ${
|
|
1066
|
-
: `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${
|
|
1124
|
+
? `${total} all in, to be paid at the hotel. ${h.name} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
|
|
1125
|
+
: `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
|
|
1067
1126
|
}
|
|
1068
1127
|
const lapses = b.holdUntilLocal
|
|
1069
1128
|
? `${b.holdUntilLocal} hotel time`
|
|
1070
1129
|
: `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
|
|
1071
1130
|
// ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
|
|
1072
1131
|
// booking has no check-in, and saying so sent a guest towards a real desk.
|
|
1073
|
-
if (
|
|
1074
|
-
return `${total} all in. **${demoBookingWords(
|
|
1132
|
+
if (h.demo)
|
|
1133
|
+
return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
|
|
1075
1134
|
return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and ${b.guest.name} presents a card at check-in.`;
|
|
1076
1135
|
};
|
|
1077
1136
|
/** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
|
|
1078
|
-
const demoBookingWords = (
|
|
1137
|
+
const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
|
|
1079
1138
|
/**
|
|
1080
1139
|
* Said on every answer about a demonstration booking - made, changed or
|
|
1081
1140
|
* cancelled - and on nothing else: a hold of the demo's that a guest took for
|
|
1082
1141
|
* the hotel's would send them to a desk with a code nobody there has.
|
|
1083
1142
|
*/
|
|
1084
|
-
const demoBookingNote = (deps) =>
|
|
1085
|
-
|
|
1086
|
-
: '';
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
: deps.
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
:
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1143
|
+
const demoBookingNote = (deps, supplier) => {
|
|
1144
|
+
const h = holderOf(deps, supplier);
|
|
1145
|
+
return h.demo && h.caps.booking === 'hold' ? demoBookingWords(h) : '';
|
|
1146
|
+
};
|
|
1147
|
+
const common = (deps, key) => {
|
|
1148
|
+
const caps = deps.backend.capabilities ?? FULL_CAPABILITIES;
|
|
1149
|
+
return {
|
|
1150
|
+
brand: deps.vocab.brand,
|
|
1151
|
+
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
1152
|
+
toolNames: deps.offered
|
|
1153
|
+
? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
|
|
1154
|
+
: deps.tools,
|
|
1155
|
+
bookingMode: caps.booking,
|
|
1156
|
+
...(caps.requiresPhone ? { phoneRequired: true } : {}),
|
|
1157
|
+
...(deps.vocab.sandbox ? { sandbox: true } : {}),
|
|
1158
|
+
...(caps.programme ? { programme: caps.programme } : {}),
|
|
1159
|
+
...(caps.codePrefix ? { codePrefix: caps.codePrefix } : {}),
|
|
1160
|
+
// A market: the card draws each hotel and booking as its group's.
|
|
1161
|
+
...(deps.backend.suppliers
|
|
1162
|
+
? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
|
|
1163
|
+
: {}),
|
|
1164
|
+
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
1165
|
+
...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
|
|
1166
|
+
...(key ? memberBadge(deps, key) : {}),
|
|
1167
|
+
};
|
|
1168
|
+
};
|
|
1102
1169
|
/**
|
|
1103
1170
|
* When a room stops being free to cancel, the way a guest reads it: the
|
|
1104
1171
|
* hotel's own clock when the backend knows it, else the instant in UTC - never
|
|
@@ -1107,10 +1174,12 @@ const common = (deps, key) => ({
|
|
|
1107
1174
|
const freeUntilText = (r) => r.freeCancellationLocal ??
|
|
1108
1175
|
`${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
|
|
1109
1176
|
/** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
|
|
1110
|
-
const quoteForCard = (quote) => ({
|
|
1177
|
+
const quoteForCard = (quote, groupOf) => ({
|
|
1111
1178
|
lines: quote.lines.map((l) => ({
|
|
1112
1179
|
kind: l.kind,
|
|
1113
1180
|
hotelId: l.hotelId,
|
|
1181
|
+
// In a market: whose stay this is, so the card books it with that group.
|
|
1182
|
+
...(groupOf?.(l.hotelId) ? { supplier: groupOf(l.hotelId) } : {}),
|
|
1114
1183
|
hotelName: l.hotelName,
|
|
1115
1184
|
roomRef: l.roomRef,
|
|
1116
1185
|
extraRef: l.extraRef,
|
|
@@ -1641,12 +1710,15 @@ const currentTrip = async (deps, key, who) => {
|
|
|
1641
1710
|
const [only] = parts;
|
|
1642
1711
|
const orphans = needsRoomIn(deps.tools, lines, parts);
|
|
1643
1712
|
const needs = orphans.needsRoom.length ? { needsRoom: orphans.needsRoom } : {};
|
|
1713
|
+
const groupOf = deps.backend.supplierOf
|
|
1714
|
+
? (id) => deps.backend.supplierOf?.(id)
|
|
1715
|
+
: undefined;
|
|
1644
1716
|
if (parts.length === 1 && only) {
|
|
1645
1717
|
return {
|
|
1646
1718
|
structured: {
|
|
1647
1719
|
view: 'trip',
|
|
1648
1720
|
...common(deps, key),
|
|
1649
|
-
...quoteForCard(only),
|
|
1721
|
+
...quoteForCard(only, groupOf),
|
|
1650
1722
|
...needs,
|
|
1651
1723
|
count: lines.length,
|
|
1652
1724
|
},
|
|
@@ -1655,7 +1727,7 @@ const currentTrip = async (deps, key, who) => {
|
|
|
1655
1727
|
.join('\n'),
|
|
1656
1728
|
};
|
|
1657
1729
|
}
|
|
1658
|
-
const shaped = parts.map(quoteForCard);
|
|
1730
|
+
const shaped = parts.map((p) => quoteForCard(p, groupOf));
|
|
1659
1731
|
const bookable = parts.map((p) => p.lines.some((l) => l.kind === 'room'));
|
|
1660
1732
|
return {
|
|
1661
1733
|
structured: {
|
|
@@ -1705,6 +1777,7 @@ const filtersOf = (args) => {
|
|
|
1705
1777
|
: [];
|
|
1706
1778
|
const f = {
|
|
1707
1779
|
...(str(args.brand) ? { brand: str(args.brand) } : {}),
|
|
1780
|
+
...(str(args.group) ? { supplier: str(args.group) } : {}),
|
|
1708
1781
|
...(facilities.length ? { facilities } : {}),
|
|
1709
1782
|
...(args.freeCancellation === true ? { freeCancellation: true } : {}),
|
|
1710
1783
|
...(args.breakfast === true ? { breakfast: true } : {}),
|
|
@@ -1748,7 +1821,16 @@ const refineFor = (r, on, total,
|
|
|
1748
1821
|
dated = false) => {
|
|
1749
1822
|
const narrows = (count, active) => active || (count > 0 && count < total);
|
|
1750
1823
|
const brands = r.brands.filter((b) => narrows(b.count, b.name === on?.brand));
|
|
1824
|
+
// In a market: the groups, by id - the card names them from `market`.
|
|
1825
|
+
const groups = (r.suppliers ?? []).filter((x) => narrows(x.count, x.id === on?.supplier));
|
|
1751
1826
|
return {
|
|
1827
|
+
...(r.suppliers
|
|
1828
|
+
? {
|
|
1829
|
+
groups: groups.length > 1 || on?.supplier
|
|
1830
|
+
? groups.map((x) => ({ ...x, on: x.id === on?.supplier }))
|
|
1831
|
+
: [],
|
|
1832
|
+
}
|
|
1833
|
+
: {}),
|
|
1752
1834
|
brands: brands.length > 1 || on?.brand ? brands.map((b) => ({ ...b, on: b.name === on?.brand })) : [],
|
|
1753
1835
|
facilities: r.facilities
|
|
1754
1836
|
.filter((x) => narrows(x.count, !!on?.facilities?.includes(x.key)))
|
|
@@ -1945,6 +2027,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1945
2027
|
: undefined,
|
|
1946
2028
|
filters: filtersOf(args),
|
|
1947
2029
|
};
|
|
2030
|
+
// A group as a guest names it, to the id the market files it under.
|
|
2031
|
+
const named = ask.filters?.supplier ? groupNamed(deps, ask.filters.supplier) : undefined;
|
|
2032
|
+
if (ask.filters?.supplier && named)
|
|
2033
|
+
ask.filters = { ...ask.filters, supplier: named.id };
|
|
1948
2034
|
const who = identity(deps, ctx, key, args);
|
|
1949
2035
|
let answer = await deps.backend.searchHotels(ask, who);
|
|
1950
2036
|
// A brand as the data spells it, so its chip shows as ON and can be turned
|
|
@@ -2110,7 +2196,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2110
2196
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
2111
2197
|
...(today ? { today } : {}),
|
|
2112
2198
|
},
|
|
2113
|
-
text: `${total} ${deps.vocab
|
|
2199
|
+
text: `${total} ${branded(deps.vocab, n)} in ${only?.name}, in ${only?.cities.length} cities to choose from: ${every(only?.cities ?? [])}. A city lists its ${n}; \`all\` lists every one in ${only?.name}.`,
|
|
2114
2200
|
};
|
|
2115
2201
|
}
|
|
2116
2202
|
const regions = [
|
|
@@ -2134,7 +2220,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2134
2220
|
...(ask.stay ? { stay: ask.stay } : {}),
|
|
2135
2221
|
...(today ? { today } : {}),
|
|
2136
2222
|
},
|
|
2137
|
-
text: `${total} ${deps.vocab
|
|
2223
|
+
text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} countries, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${every(r.countries)}`).join('; ')}. A city or a country lists its ${n}.`,
|
|
2138
2224
|
};
|
|
2139
2225
|
}
|
|
2140
2226
|
if (hotels.length === 0) {
|
|
@@ -2168,7 +2254,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2168
2254
|
: ceilingEmpty
|
|
2169
2255
|
? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
|
|
2170
2256
|
: [
|
|
2171
|
-
`No ${deps.vocab
|
|
2257
|
+
`No ${branded(deps.vocab, deps.vocab.nounPlural)} match that.`,
|
|
2172
2258
|
// What was tried and also failed is worth a sentence: it tells a
|
|
2173
2259
|
// guest the word itself is the problem, not the narrowing.
|
|
2174
2260
|
widened ? `${sentence(widened.to)} found nothing either.` : '',
|
|
@@ -2280,7 +2366,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2280
2366
|
...(answer.currencies.length > 1 ? { currencies: answer.currencies } : {}),
|
|
2281
2367
|
},
|
|
2282
2368
|
text: [
|
|
2283
|
-
`${total} ${deps.vocab
|
|
2369
|
+
`${total} ${branded(deps.vocab, total === 1 ? deps.vocab.noun : deps.vocab.nounPlural)}${where}.`,
|
|
2284
2370
|
// ⚠️ NO "BELOW", NO "TAP". This sentence is read out by a voice
|
|
2285
2371
|
// assistant as often as it is drawn next to a card, and a guest whose
|
|
2286
2372
|
// host has no card at all cannot tap the one it tells them to tap. The
|
|
@@ -2303,7 +2389,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2303
2389
|
if (!detail) {
|
|
2304
2390
|
return {
|
|
2305
2391
|
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
2306
|
-
text: `No ${deps.vocab
|
|
2392
|
+
text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name. ${t.listHotels} lists them.`,
|
|
2307
2393
|
};
|
|
2308
2394
|
}
|
|
2309
2395
|
const h = detail.hotel;
|
|
@@ -2460,9 +2546,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2460
2546
|
...(dropped.length ? { dropped } : {}),
|
|
2461
2547
|
},
|
|
2462
2548
|
text: city
|
|
2463
|
-
? `No ${deps.vocab
|
|
2549
|
+
? `No ${branded(deps.vocab, deps.vocab.nounPlural)} in "${city}". ${t.listHotels} with no place shows the places there are.`
|
|
2464
2550
|
: named.length
|
|
2465
|
-
? `No ${deps.vocab
|
|
2551
|
+
? `No ${branded(deps.vocab, deps.vocab.noun)} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
|
|
2466
2552
|
: `Name a \`city\`, or up to ${LOOK_UP_MAX} ${deps.vocab.nounPlural} in \`${deps.vocab.nounPlural}\`.`,
|
|
2467
2553
|
};
|
|
2468
2554
|
}
|
|
@@ -2578,7 +2664,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2578
2664
|
if (!detail) {
|
|
2579
2665
|
return {
|
|
2580
2666
|
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
2581
|
-
text: `No ${deps.vocab
|
|
2667
|
+
text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
|
|
2582
2668
|
};
|
|
2583
2669
|
}
|
|
2584
2670
|
const extras = await deps.backend.extrasFor(detail.hotel.id, booked?.stay ?? asStay(args));
|
|
@@ -2622,7 +2708,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2622
2708
|
error: 'hotel-not-found',
|
|
2623
2709
|
query: wanted,
|
|
2624
2710
|
},
|
|
2625
|
-
text: `No ${deps.vocab
|
|
2711
|
+
text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
|
|
2626
2712
|
};
|
|
2627
2713
|
}
|
|
2628
2714
|
const hotelId = detail.hotel.id;
|
|
@@ -2638,9 +2724,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2638
2724
|
error: 'no-such-extra',
|
|
2639
2725
|
query: extraRef,
|
|
2640
2726
|
},
|
|
2641
|
-
text: (deps.
|
|
2727
|
+
text: holderOf(deps, detail.hotel.supplier).caps.extras
|
|
2642
2728
|
? `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`
|
|
2643
|
-
: `${deps.
|
|
2729
|
+
: `${holderOf(deps, detail.hotel.supplier).name} ${deps.vocab.nounPlural} sell rooms only here - there are no add-ons.`,
|
|
2644
2730
|
};
|
|
2645
2731
|
}
|
|
2646
2732
|
const line = {
|
|
@@ -2723,17 +2809,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2723
2809
|
};
|
|
2724
2810
|
}
|
|
2725
2811
|
if (name === t.requestBooking) {
|
|
2726
|
-
const
|
|
2727
|
-
if (
|
|
2812
|
+
const inTrip = deps.trips.read(key);
|
|
2813
|
+
if (inTrip.length === 0) {
|
|
2728
2814
|
return {
|
|
2729
2815
|
structured: { view: 'empty-trip', ...common(deps, key) },
|
|
2730
2816
|
text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
|
|
2731
2817
|
};
|
|
2732
2818
|
}
|
|
2819
|
+
// ⚠️ ONE GROUP AT A TIME, in a market: a booking cannot be all-or-nothing
|
|
2820
|
+
// across two groups' systems (Marketplace, §6-1). `group` books one
|
|
2821
|
+
// group's stays and leaves the rest in the trip.
|
|
2822
|
+
const asked = deps.backend.suppliers ? str(args.group) : '';
|
|
2823
|
+
const group = asked ? groupNamed(deps, asked) : undefined;
|
|
2824
|
+
const groupOf = (l) => deps.backend.supplierOf?.(l.hotelId);
|
|
2825
|
+
const lines = group ? inTrip.filter((l) => groupOf(l) === group.id) : inTrip;
|
|
2826
|
+
if (asked && (!group || lines.length === 0)) {
|
|
2827
|
+
const there = [...new Set(inTrip.map(groupOf))]
|
|
2828
|
+
.map((id) => holderOf(deps, id).name)
|
|
2829
|
+
.filter(Boolean);
|
|
2830
|
+
const words = group
|
|
2831
|
+
? `Nothing in the trip is with ${group.name}. It holds stays with ${listAnd(there)}.`
|
|
2832
|
+
: `No hotel group here is called "${asked}". The trip holds stays with ${listAnd(there)}.`;
|
|
2833
|
+
return {
|
|
2834
|
+
structured: { view: 'error', ...common(deps, key), error: 'no-such-group', detail: words },
|
|
2835
|
+
text: words,
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2733
2838
|
const guest = { name: str(args.name), email: str(args.email) };
|
|
2734
2839
|
// A phone where the backend needs one: a booking paid at the hotel and
|
|
2735
2840
|
// guaranteed by nobody, which the hotel has to be able to follow up.
|
|
2736
|
-
|
|
2841
|
+
// In a market, the trip's own groups decide - not whichever group needs most.
|
|
2842
|
+
const phoneNeeded = deps.backend.suppliers
|
|
2843
|
+
? lines.some((l) => !!holderOf(deps, deps.backend.supplierOf?.(l.hotelId)).caps.requiresPhone)
|
|
2844
|
+
: !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
|
|
2737
2845
|
const phone = str(args.phone);
|
|
2738
2846
|
const missing = [
|
|
2739
2847
|
!guest.name && 'a name',
|
|
@@ -2768,7 +2876,16 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2768
2876
|
// only place an Identity is built; the guest's email still overrides,
|
|
2769
2877
|
// because a booking is found by the address it was made with.
|
|
2770
2878
|
{ ...identity(deps, ctx, key), email: guest.email }, requests);
|
|
2771
|
-
|
|
2879
|
+
if (lines.length === inTrip.length)
|
|
2880
|
+
deps.trips.clear(key);
|
|
2881
|
+
else
|
|
2882
|
+
for (const l of lines)
|
|
2883
|
+
deps.trips.set(key, unset(l));
|
|
2884
|
+
// What is left is priced again, and that price is what the next
|
|
2885
|
+
// group's booking is held to (ShownQuote).
|
|
2886
|
+
const rest = lines.length === inTrip.length
|
|
2887
|
+
? undefined
|
|
2888
|
+
: await currentTrip(deps, key, identity(deps, ctx, key));
|
|
2772
2889
|
// ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
|
|
2773
2890
|
// way, so neither the guest nor the model assumes something was kept.
|
|
2774
2891
|
const signedIn = deps.members.read(key);
|
|
@@ -2853,7 +2970,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2853
2970
|
},
|
|
2854
2971
|
text: [
|
|
2855
2972
|
several
|
|
2856
|
-
? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${(deps.
|
|
2973
|
+
? `${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.`
|
|
2857
2974
|
: '',
|
|
2858
2975
|
...booked.flatMap(({ booking, asked }) => [
|
|
2859
2976
|
`**${booking.confirmationCode}** · ${booking.hotelName} · ${booking.stay.checkIn} to ${booking.stay.checkOut}`,
|
|
@@ -2861,6 +2978,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2861
2978
|
...asked,
|
|
2862
2979
|
]),
|
|
2863
2980
|
remembered?.line ?? '',
|
|
2981
|
+
rest ? `The trip's other stays are still in it, not booked:\n${rest.text}` : '',
|
|
2864
2982
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2865
2983
|
]
|
|
2866
2984
|
.filter(Boolean)
|
|
@@ -2876,6 +2994,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2876
2994
|
const again = await currentTrip(deps, key, identity(deps, ctx, key));
|
|
2877
2995
|
return { ...again, text: [words, again.text].join('\n\n') };
|
|
2878
2996
|
}
|
|
2997
|
+
if (err.code === 'mixed-supplier') {
|
|
2998
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key));
|
|
2999
|
+
return {
|
|
3000
|
+
...again,
|
|
3001
|
+
text: [
|
|
3002
|
+
words,
|
|
3003
|
+
`${t.requestBooking} with \`group\` books one group's stays and leaves the others in the trip.`,
|
|
3004
|
+
again.text,
|
|
3005
|
+
].join('\n\n'),
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
2879
3008
|
return {
|
|
2880
3009
|
structured: {
|
|
2881
3010
|
view: 'error',
|
|
@@ -2992,7 +3121,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
2992
3121
|
: 'The total is unchanged.',
|
|
2993
3122
|
preview.rule,
|
|
2994
3123
|
`Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
|
|
2995
|
-
demoBookingNote(deps),
|
|
3124
|
+
demoBookingNote(deps, preview.supplier),
|
|
2996
3125
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
2997
3126
|
]
|
|
2998
3127
|
.filter(Boolean)
|
|
@@ -3055,7 +3184,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3055
3184
|
text: [
|
|
3056
3185
|
`${changed.confirmationCode} now holds ${changed.stay.checkIn} to ${changed.stay.checkOut}, ${formatMoney(changed.quote.total)} all in.`,
|
|
3057
3186
|
'The confirmation code has not changed. **Nothing has been charged.**',
|
|
3058
|
-
demoBookingNote(deps),
|
|
3187
|
+
demoBookingNote(deps, changed.supplier),
|
|
3059
3188
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
3060
3189
|
]
|
|
3061
3190
|
.filter(Boolean)
|
|
@@ -3103,7 +3232,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3103
3232
|
preview.cancellable
|
|
3104
3233
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
3105
3234
|
: '',
|
|
3106
|
-
demoBookingNote(deps),
|
|
3235
|
+
demoBookingNote(deps, preview.supplier),
|
|
3107
3236
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
3108
3237
|
]
|
|
3109
3238
|
.filter(Boolean)
|
|
@@ -3139,6 +3268,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3139
3268
|
// the rate's terms, and where the two differ the hotel's is the one that
|
|
3140
3269
|
// will be charged.
|
|
3141
3270
|
const charged = cancelled.cancellationFee ?? preview.fee;
|
|
3271
|
+
const h = holderOf(deps, cancelled.supplier);
|
|
3142
3272
|
return {
|
|
3143
3273
|
structured: {
|
|
3144
3274
|
view: 'cancelled',
|
|
@@ -3149,14 +3279,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3149
3279
|
cancellable: false,
|
|
3150
3280
|
},
|
|
3151
3281
|
text: [
|
|
3152
|
-
|
|
3282
|
+
h.sandbox
|
|
3153
3283
|
? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
|
|
3154
3284
|
`${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.`}`
|
|
3155
|
-
:
|
|
3285
|
+
: h.demo
|
|
3156
3286
|
? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
3157
3287
|
`${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.`}`
|
|
3158
3288
|
: `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
|
|
3159
|
-
demoBookingNote(deps),
|
|
3289
|
+
demoBookingNote(deps, cancelled.supplier),
|
|
3160
3290
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
3161
3291
|
]
|
|
3162
3292
|
.filter(Boolean)
|
|
@@ -3185,7 +3315,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3185
3315
|
...common(deps, key),
|
|
3186
3316
|
dropped: comparison.dropped,
|
|
3187
3317
|
},
|
|
3188
|
-
text: `None of those are ${deps.vocab
|
|
3318
|
+
text: `None of those are ${catalogue(deps.vocab)}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
|
|
3189
3319
|
};
|
|
3190
3320
|
}
|
|
3191
3321
|
return {
|
|
@@ -3341,7 +3471,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
3341
3471
|
if (!detail) {
|
|
3342
3472
|
return {
|
|
3343
3473
|
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
3344
|
-
text: `No ${deps.vocab
|
|
3474
|
+
text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
|
|
3345
3475
|
};
|
|
3346
3476
|
}
|
|
3347
3477
|
const policies = await deps.backend.policies(detail.hotel.id);
|