@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.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/dist/aggregate/hotels.d.ts +45 -0
  3. package/dist/aggregate/hotels.d.ts.map +1 -0
  4. package/dist/aggregate/hotels.js +633 -0
  5. package/dist/aggregate/hotels.js.map +1 -0
  6. package/dist/aggregate/ids.d.ts +36 -0
  7. package/dist/aggregate/ids.d.ts.map +1 -0
  8. package/dist/aggregate/ids.js +64 -0
  9. package/dist/aggregate/ids.js.map +1 -0
  10. package/dist/aggregate/make.d.ts +12 -0
  11. package/dist/aggregate/make.d.ts.map +1 -0
  12. package/dist/aggregate/make.js +96 -0
  13. package/dist/aggregate/make.js.map +1 -0
  14. package/dist/aggregate/paging.d.ts +35 -0
  15. package/dist/aggregate/paging.d.ts.map +1 -0
  16. package/dist/aggregate/paging.js +94 -0
  17. package/dist/aggregate/paging.js.map +1 -0
  18. package/dist/backend.d.ts +41 -0
  19. package/dist/backend.d.ts.map +1 -1
  20. package/dist/backend.js.map +1 -1
  21. package/dist/card.generated.d.ts +2 -2
  22. package/dist/card.generated.d.ts.map +1 -1
  23. package/dist/card.generated.js +3 -3
  24. package/dist/card.generated.js.map +1 -1
  25. package/dist/config.d.ts +31 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js.map +1 -1
  28. package/dist/demo-hotels.d.ts +6 -0
  29. package/dist/demo-hotels.d.ts.map +1 -1
  30. package/dist/demo-hotels.js +16 -6
  31. package/dist/demo-hotels.js.map +1 -1
  32. package/dist/huazhu/backend.d.ts.map +1 -1
  33. package/dist/huazhu/backend.js +2 -0
  34. package/dist/huazhu/backend.js.map +1 -1
  35. package/dist/huazhu/make.d.ts +4 -0
  36. package/dist/huazhu/make.d.ts.map +1 -0
  37. package/dist/huazhu/make.js +105 -0
  38. package/dist/huazhu/make.js.map +1 -0
  39. package/dist/huazhu/sandbox-photos.d.ts +25 -0
  40. package/dist/huazhu/sandbox-photos.d.ts.map +1 -0
  41. package/dist/huazhu/sandbox-photos.js +514 -0
  42. package/dist/huazhu/sandbox-photos.js.map +1 -0
  43. package/dist/huazhu/sandbox.d.ts +4 -2
  44. package/dist/huazhu/sandbox.d.ts.map +1 -1
  45. package/dist/huazhu/sandbox.js +763 -45
  46. package/dist/huazhu/sandbox.js.map +1 -1
  47. package/dist/index.js +0 -0
  48. package/dist/legal.d.ts +1 -0
  49. package/dist/legal.d.ts.map +1 -1
  50. package/dist/legal.js +188 -66
  51. package/dist/legal.js.map +1 -1
  52. package/dist/lib.d.ts +1 -1
  53. package/dist/lib.d.ts.map +1 -1
  54. package/dist/server.d.ts +10 -22
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +40 -153
  57. package/dist/server.js.map +1 -1
  58. package/dist/state.d.ts +8 -0
  59. package/dist/state.d.ts.map +1 -1
  60. package/dist/state.js +18 -1
  61. package/dist/state.js.map +1 -1
  62. package/dist/supplier.d.ts +50 -0
  63. package/dist/supplier.d.ts.map +1 -0
  64. package/dist/supplier.js +57 -0
  65. package/dist/supplier.js.map +1 -0
  66. package/dist/surface.d.ts.map +1 -1
  67. package/dist/surface.js +37 -21
  68. package/dist/surface.js.map +1 -1
  69. package/dist/tools.d.ts +29 -23
  70. package/dist/tools.d.ts.map +1 -1
  71. package/dist/tools.js +197 -67
  72. package/dist/tools.js.map +1 -1
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/vocab.d.ts +20 -0
  76. package/dist/vocab.d.ts.map +1 -1
  77. package/dist/vocab.js +22 -1
  78. package/dist/vocab.js.map +1 -1
  79. 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
- if (caps.booking === 'pay-at-hotel') {
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.brand} ${v.nounPlural}, 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}` : ''}`,
186
- annotations: reads(`Browse ${v.brand} ${v.nounPlural}`, open),
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.brand} ${v.noun}`, open),
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.brand} ${v.nounPlural}`, open),
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.demo
371
- ? `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.`
372
- : `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.`,
373
- annotations: books(`Request a ${v.brand} booking`, open),
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.brand} ${v.nounPlural}`, open),
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
- if ((deps.backend.capabilities ?? FULL_CAPABILITIES).booking === 'pay-at-hotel') {
1062
- if (deps.vocab.sandbox)
1063
- return `${total} in the simulated system. **This is a test booking** - not a ${deps.vocab.brand} reservation: no room is held and nothing is paid, now or at any hotel.`;
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. ${deps.vocab.brand} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1066
- : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${deps.vocab.brand}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
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 (deps.vocab.demo)
1074
- return `${total} all in. **${demoBookingWords(deps)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
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 = (deps) => `This is a demonstration booking: ${deps.vocab.brand} has not received it and is not holding any room.`;
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) => deps.vocab.demo && (deps.backend.capabilities ?? FULL_CAPABILITIES).booking === 'hold'
1085
- ? demoBookingWords(deps)
1086
- : '';
1087
- const common = (deps, key) => ({
1088
- brand: deps.vocab.brand,
1089
- ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
1090
- toolNames: deps.offered
1091
- ? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
1092
- : deps.tools,
1093
- bookingMode: (deps.backend.capabilities ?? FULL_CAPABILITIES).booking,
1094
- ...((deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone
1095
- ? { phoneRequired: true }
1096
- : {}),
1097
- ...(deps.vocab.sandbox ? { sandbox: true } : {}),
1098
- vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1099
- ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
1100
- ...(key ? memberBadge(deps, key) : {}),
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.brand} ${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}.`,
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.brand} ${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}.`,
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.brand} ${deps.vocab.nounPlural} match that.`,
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.brand} ${total === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where}.`,
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.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
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.brand} ${deps.vocab.nounPlural} in "${city}". ${t.listHotels} with no place shows the places there are.`
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.brand} ${deps.vocab.noun} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
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.brand} ${deps.vocab.noun} by that name.`,
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.brand} ${deps.vocab.noun} by that name.`,
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.backend.capabilities ?? FULL_CAPABILITIES).extras
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.vocab.brand} ${deps.vocab.nounPlural} sell rooms only here - there are no add-ons.`,
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 lines = deps.trips.read(key);
2727
- if (lines.length === 0) {
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
- const phoneNeeded = !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
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
- deps.trips.clear(key);
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.backend.capabilities ?? FULL_CAPABILITIES).changes ? 'changed or cancelled' : 'cancelled'} on its own.`
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
- deps.vocab.sandbox
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
- : deps.vocab.demo
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.brand} ${deps.vocab.nounPlural}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
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.brand} ${deps.vocab.noun} by that name.`,
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);