@curless/hotel-storefront 0.50.5 → 0.51.1

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/tools.js CHANGED
@@ -100,20 +100,40 @@ export const buildTools = (n, v, caps = FULL_CAPABILITIES) => forCapabilities(al
100
100
  * another city (three smoke runs did). Where it is a few (`HOME_LIST_MAX`,
101
101
  * Huazhu's production, 2026-10-30), asked with no place it lists all of them.
102
102
  * Never in a market.
103
+ *
104
+ * ⚠️ `all` SAYS SO TOO (0.51.1): its "true lists the hotels themselves" is
105
+ * what had the model open Huazhu's production with `all: true` - the one way
106
+ * past the home, with no tonight and no prices. It is the home there now, and
107
+ * its description says it changes nothing.
103
108
  */
104
109
  export const homeListing = (t, brand, count) => {
105
110
  const v = brand.vocab;
106
111
  if (t.name !== brand.tools.listHotels || v.market || count < 1 || count > HOME_LIST_MAX)
107
112
  return t;
108
- const one = count === 1
109
- ? `This deployment has ONE ${v.noun} in all: asked with no place, it answers with that ${v.noun}'s own page - priced for tonight when no dates are given - and there is no other ${v.noun} or city to list or search. `
110
- : `This deployment has ${count} ${v.nounPlural} in all: asked with no place, it lists every one of them - priced for tonight when no dates are given - and there is no other ${v.noun} or city to list or search. `;
113
+ const home = count === 1 ? `it answers with that ${v.noun}'s own page` : 'it lists every one of them';
114
+ const one = `This deployment has ${count === 1 ? 'ONE' : count} ${count === 1 ? v.noun : v.nounPlural} in all: asked with no place, or with \`all\`, ${home}; any list here without dates is priced for tonight; and there is no other ${v.noun} or city to list or search. `;
111
115
  const places = /Asked with no place[\s\S]*?themselves\. /;
116
+ const schema = t.inputSchema;
117
+ const all = schema?.properties?.all;
112
118
  return {
113
119
  ...t,
114
120
  description: places.test(t.description)
115
121
  ? t.description.replace(places, one)
116
122
  : `${one}${t.description}`,
123
+ ...(schema && all
124
+ ? {
125
+ inputSchema: {
126
+ ...schema,
127
+ properties: {
128
+ ...schema.properties,
129
+ all: {
130
+ ...all,
131
+ description: `Changes nothing here: with ${count === 1 ? `one ${v.noun}` : `${count} ${v.nounPlural}`} in all, asked with no place ${home} either way`,
132
+ },
133
+ },
134
+ },
135
+ }
136
+ : {}),
117
137
  };
118
138
  };
119
139
  export const withheldTools = (n, caps) => new Set([
@@ -200,6 +220,44 @@ const forCapabilities = (tools, n, v, caps) => {
200
220
  }),
201
221
  };
202
222
  }
223
+ // ⚠️ ONLY WHERE COUPONS ARE TAKEN (`capabilities.coupons`): a group that
224
+ // takes none is offered no parameter for one.
225
+ if (caps.coupons) {
226
+ const withCoupon = (schema, description) => {
227
+ const s = schema;
228
+ return {
229
+ ...s,
230
+ properties: { ...(s.properties ?? {}), coupon: { type: 'string', description } },
231
+ };
232
+ };
233
+ if (t.name === n.viewTrip)
234
+ out = {
235
+ ...out,
236
+ description: `${out.description} With \`coupon\`, priced with that coupon taken off, after the member rate - nothing is spent by looking.`,
237
+ inputSchema: withCoupon(out.inputSchema, 'A coupon the signed-in member holds, by the id the sign-in answer gave it. Only one the guest named or agreed to - never one picked for them'),
238
+ };
239
+ if (t.name === n.requestBooking)
240
+ out = {
241
+ ...out,
242
+ inputSchema: withCoupon(out.inputSchema, 'A coupon the signed-in member holds, by its id: used on this booking, which spends it. Only when the guest named it or agreed to it - the one the price they agreed to was shown with. If it cannot be used, nothing is booked'),
243
+ };
244
+ // The card's stepper and Remove on a trip priced with a coupon keep it.
245
+ if (t.name === n.addToTrip)
246
+ out = {
247
+ ...out,
248
+ inputSchema: withCoupon(out.inputSchema, 'The coupon this trip is already priced with, by its id, to keep it on the new price - nothing is spent. Only one the guest named or agreed to'),
249
+ };
250
+ if (t.name === n.pickRoom || t.name === n.bookPicked)
251
+ out = {
252
+ ...out,
253
+ inputSchema: withCoupon(out.inputSchema, 'The coupon the guest chose on the card, by its id'),
254
+ };
255
+ if (t.name === n.signIn)
256
+ out = {
257
+ ...out,
258
+ description: out.description.replace('and answers with their tier, their point balance', 'and answers with their tier, their point balance, the coupons they hold'),
259
+ };
260
+ }
203
261
  // ⚠️ THE PICKED ROOM'S BOOKING TAKES THE SAME GUEST FIELDS as the trip's:
204
262
  // a phone where the hotel needs one, and no `rememberRequests` without
205
263
  // members. Its description is its own (below, only request_booking's).
@@ -995,6 +1053,10 @@ const TRIP_SCHEMA = {
995
1053
  member: OBJ,
996
1054
  /** What the tier took off THIS trip, and what it earns. */
997
1055
  memberSaving: OBJ,
1056
+ /** The coupon taken off, the one asked for and refused, and every one the member holds. */
1057
+ coupon: OBJ,
1058
+ couponRefused: OBJ,
1059
+ coupons: ARR,
998
1060
  days: ARR,
999
1061
  dropped: ARR,
1000
1062
  /** A name that fits more than one hotel: the ones it fits (`namesLike`). */
@@ -1008,6 +1070,8 @@ const CANCEL_SCHEMA = {
1008
1070
  type: 'object',
1009
1071
  properties: {
1010
1072
  view: { enum: ['cancellation-preview', 'cancelled', 'booking-not-found'] },
1073
+ /** The coupon the booking used and whether cancelling gives it back. */
1074
+ coupon: OBJ,
1011
1075
  brand: STR,
1012
1076
  toolNames: OBJ,
1013
1077
  bookingMode: STR,
@@ -1140,6 +1204,8 @@ const HOTEL_PAGE = {
1140
1204
  needsDates: BOOL,
1141
1205
  /** How many rooms of one type the list was filtered to. */
1142
1206
  rooms_wanted: { type: 'number' },
1207
+ /** Signed in: whose rate the prices are, said as the text says it (`said`). */
1208
+ memberRates: OBJ,
1143
1209
  noRooms: STR,
1144
1210
  /** `noRooms` as a code (`NoRoomsCode`) and what it holds, for a card to say in the guest's language. */
1145
1211
  noRoomsCode: STR,
@@ -1183,6 +1249,11 @@ const BOOKING_SCHEMA = {
1183
1249
  // `trip`: nothing was booked and the trip is shown again - its price
1184
1250
  // moved, or (in a market) it holds two groups' stays.
1185
1251
  view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error', 'trip'] },
1252
+ /** `coupon`: nothing was booked because the coupon asked for cannot be used (`couponRefused`). */
1253
+ notBooked: STR,
1254
+ coupon: OBJ,
1255
+ couponRefused: OBJ,
1256
+ coupons: ARR,
1186
1257
  brand: STR,
1187
1258
  toolNames: OBJ,
1188
1259
  bookingMode: STR,
@@ -1519,10 +1590,25 @@ const memberBadge = (deps, key) => {
1519
1590
  programme: m.programme,
1520
1591
  tier: { name: m.tier.name, discount: m.tier.discount },
1521
1592
  ...(m.preferences?.length ? { preferences: m.preferences } : {}),
1593
+ // As they stand now - spent or handed back since signing in - so
1594
+ // the member page the badge opens is never a booking behind.
1595
+ points: m.points,
1596
+ ...(m.pointsExpiring ? { pointsExpiring: m.pointsExpiring } : {}),
1597
+ ...(m.coupons ? { coupons: m.coupons.map(couponForCard) } : {}),
1522
1598
  },
1523
1599
  }
1524
1600
  : { signedIn: false };
1525
1601
  };
1602
+ /** A member for the card: every amount as text, like every amount the card is given. */
1603
+ const memberForCard = (m, brand) => ({
1604
+ ...m,
1605
+ // Each with its terms in the sign-in text's own words (\`said\`): the model
1606
+ // reads this, and a coupon for every hotel had no scope here at all - so
1607
+ // "全部门店" went unsaid (0.51.0 eval).
1608
+ ...(m.coupons
1609
+ ? { coupons: m.coupons.map((c) => ({ ...couponForCard(c), said: couponTerms(c, brand) })) }
1610
+ : {}),
1611
+ });
1526
1612
  /** A market's group by its id or its name, as a guest or a model would say it. */
1527
1613
  /** How many stays that failed are each given alternatives - each is a search. */
1528
1614
  const MAX_FAILED_WITH_ALTERNATIVES = 3;
@@ -1779,7 +1865,22 @@ const madeForCard = (deps, b) => {
1779
1865
  const { confirmationCode: _unconfirmed, ...rest } = card;
1780
1866
  return { ...rest, pending: true };
1781
1867
  };
1782
- const bookedLine = (deps, b) => {
1868
+ const bookedLine = (deps, b) => [heldLine(deps, b), couponPointsLine(b)].filter(Boolean).join(' ');
1869
+ /**
1870
+ * The coupon a booking used and the points it should earn - on the booking
1871
+ * itself, so the confirmation says what the review said (§4, §5).
1872
+ */
1873
+ const couponPointsLine = (b) => [
1874
+ b.quote.coupon
1875
+ ? `Coupon ${couponName(b.quote.coupon)} used: −${formatMoney(b.quote.coupon.off)}, and ${formatMoney(b.quote.total)} is the booking's total after it.`
1876
+ : '',
1877
+ b.quote.member
1878
+ ? `About ${b.quote.member.points.toLocaleString('en-GB')} ${b.quote.member.programme} points after the stay - not added to the balance here.`
1879
+ : '',
1880
+ ]
1881
+ .filter(Boolean)
1882
+ .join(' ');
1883
+ const heldLine = (deps, b) => {
1783
1884
  const paid = paidLine(deps, b);
1784
1885
  if (paid)
1785
1886
  return paid;
@@ -1875,6 +1976,35 @@ const langOf = (deps, _key) => deps.lang;
1875
1976
  */
1876
1977
  const freeUntilText = (r) => r.freeCancellationLocal ??
1877
1978
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
1979
+ /**
1980
+ * What a signed-in member's prices are, said once at the top of a hotel's
1981
+ * rooms - with the cheapest one's public and member figures, so "member rate"
1982
+ * is a number the guest can check (10-30: the rooms were at the member rate
1983
+ * and nothing told the model so).
1984
+ */
1985
+ const memberRatesOf = (rooms, roomsWanted) => {
1986
+ const priced = rooms.filter((r) => r.memberRate && r.publicStayTotal);
1987
+ const cheapest = priced.reduce((a, r) => (!a || r.stayTotal.minor < a.stayTotal.minor ? r : a), undefined);
1988
+ const m = cheapest?.memberRate;
1989
+ if (!cheapest?.publicStayTotal || !m)
1990
+ return undefined;
1991
+ const n = Math.max(1, roomsWanted);
1992
+ return {
1993
+ programme: m.programme,
1994
+ tier: m.tier,
1995
+ discount: m.discount,
1996
+ said: `Prices here are the ${m.programme} ${m.tier} member rate, ${Math.round(m.discount * 100)}% below the public rate - the cheapest, ${cheapest.name}, is ${formatMoney(multiplyMoney(cheapest.publicStayTotal, n))} at the public rate and ${formatMoney(multiplyMoney(cheapest.stayTotal, n))} at the member rate for the stay${n > 1 ? ` (${n} rooms)` : ''}.`,
1997
+ };
1998
+ };
1999
+ /** A held coupon for the card: its amounts as text, like every amount the card is given. */
2000
+ const couponForCard = (c) => ({
2001
+ ...c,
2002
+ ...(c.amountOff ? { amountOff: formatMoney(c.amountOff) } : {}),
2003
+ ...(c.maxOff ? { maxOff: formatMoney(c.maxOff) } : {}),
2004
+ ...(c.minSpend ? { minSpend: formatMoney(c.minSpend) } : {}),
2005
+ ...(c.off ? { off: formatMoney(c.off) } : {}),
2006
+ ...(c.spend ? { spend: formatMoney(c.spend) } : {}),
2007
+ });
1878
2008
  /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
1879
2009
  const quoteForCard = (quote, groupOf) => ({
1880
2010
  lines: quote.lines.map((l) => ({
@@ -1925,11 +2055,114 @@ const quoteForCard = (quote, groupOf) => ({
1925
2055
  // the quote's block is a different shape (a tier NAME, a saving, points).
1926
2056
  // Two shapes on one key is how a card reads `member.tier.name` off a string.
1927
2057
  ...(quote.member
1928
- ? { memberSaving: { ...quote.member, saved: formatMoney(quote.member.saved) } }
2058
+ ? {
2059
+ memberSaving: {
2060
+ ...quote.member,
2061
+ saved: formatMoney(quote.member.saved),
2062
+ // The review's own order - public, member, coupon, total - each
2063
+ // figure the server's, so the card adds nothing up (10-30, ①).
2064
+ publicTotal: formatMoney(quote.member.publicTotal),
2065
+ memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
2066
+ },
2067
+ }
2068
+ : {}),
2069
+ ...(quote.coupon ? { coupon: { ...quote.coupon, off: formatMoney(quote.coupon.off) } } : {}),
2070
+ ...(quote.couponRefused
2071
+ ? {
2072
+ couponRefused: {
2073
+ ...quote.couponRefused,
2074
+ ...(quote.couponRefused.minSpend
2075
+ ? { minSpend: formatMoney(quote.couponRefused.minSpend) }
2076
+ : {}),
2077
+ ...(quote.couponRefused.spend ? { spend: formatMoney(quote.couponRefused.spend) } : {}),
2078
+ },
2079
+ }
1929
2080
  : {}),
2081
+ ...(quote.coupons ? { coupons: quote.coupons.map(couponForCard) } : {}),
1930
2082
  ...(quote.stay ? { stay: quote.stay } : {}),
1931
2083
  ...(quote.dropped ? { dropped: quote.dropped } : {}),
1932
2084
  });
2085
+ /**
2086
+ * A coupon's name as a guest of either language reads it: the programme's
2087
+ * Chinese beside its English - "满500减50 (¥50 off a stay of ¥500)".
2088
+ */
2089
+ const couponName = (c) => c.names?.zh && c.names.zh !== c.name ? `${c.names.zh} (${c.name})` : c.name;
2090
+ /** "A, B or C". */
2091
+ const listOrWords = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} or ${xs.at(-1)}` : (xs[0] ?? '');
2092
+ /** What a held coupon is - what it takes off, where, until when - and where it stands. */
2093
+ const couponTerms = (c, brand) => [
2094
+ c.kind === 'amount-off'
2095
+ ? `${c.amountOff ? formatMoney(c.amountOff) : ''} off${c.minSpend ? ` a stay of ${formatMoney(c.minSpend)} or more` : ', no minimum'}`
2096
+ : `${Math.round((c.percentOff ?? 0) * 100)}% off${c.maxOff ? `, at most ${formatMoney(c.maxOff)}` : ''}${c.minSpend ? `, on a stay of ${formatMoney(c.minSpend)} or more` : ''}`,
2097
+ c.hotels?.length
2098
+ ? `only at ${listOrWords(c.hotels.map((h) => h.name))}`
2099
+ : c.brands?.length
2100
+ ? `only at ${listOrWords(c.brands.map((b) => b.name))} hotels`
2101
+ : `at every ${brand} hotel`,
2102
+ c.state === 'expired'
2103
+ ? `ran out on ${c.validUntil} - cannot be used`
2104
+ : c.state === 'used'
2105
+ ? 'already used on a booking'
2106
+ : c.state === 'not-yet'
2107
+ ? `usable from ${c.validFrom} to ${c.validUntil}`
2108
+ : `until ${c.validUntil}`,
2109
+ ].join(' · ');
2110
+ /** What cancelling does to the coupon a booking used. */
2111
+ const couponBackWords = (c) => c.returned
2112
+ ? `The coupon ${couponName(c)} it used comes back to the member on cancelling (usable until ${c.validUntil}).`
2113
+ : `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
2114
+ /**
2115
+ * Said wherever points are: they are shown and earned, never spent here -
2116
+ * nobody has published what a point is worth (hotel-connectors.md §5).
2117
+ */
2118
+ const POINTS_DO_NOT_PAY = 'Points cannot pay for a room here, not even in part: what a point is worth has not been published, so give no rate for them.';
2119
+ /** The rule a coupon is used by, said once where coupons are listed. */
2120
+ const COUPON_RULE = 'One coupon per booking, taken off after the member rate. Use one only when the guest names it or agrees to it - using it spends it - by passing its id as `coupon` to the trip view (to price it) and to the booking.';
2121
+ /** A signed-in member's coupons and points, for the sign-in answer - see `memberCouponsSaid`. */
2122
+ const memberSaid = (m, brand) => [
2123
+ `Points: ${m.points.toLocaleString('en-GB')}.${m.pointsExpiring ? ` ${m.pointsExpiring.points.toLocaleString('en-GB')} of them lapse on ${m.pointsExpiring.on}.` : ''} ${POINTS_DO_NOT_PAY}`,
2124
+ ...(m.coupons
2125
+ ? m.coupons.length
2126
+ ? [
2127
+ `Coupons held (${m.coupons.length}): ${COUPON_RULE}`,
2128
+ // The terms say it in English; the name is the programme's own.
2129
+ ...m.coupons.map((c) => `- ${c.names?.zh ?? c.name} · ${couponTerms(c, brand)} · id \`${c.id}\``),
2130
+ ]
2131
+ : ['No coupons are held on this membership.']
2132
+ : []),
2133
+ ];
2134
+ /**
2135
+ * What membership did to this trip, in the review's own order: the public
2136
+ * rate, the member rate, the coupon, what is paid - the same three figures the
2137
+ * card shows (`memberSaving`), so text and structure agree.
2138
+ */
2139
+ const memberTripLines = (q) => {
2140
+ const m = q.member;
2141
+ if (!m)
2142
+ return [];
2143
+ const cur = q.total.currency;
2144
+ const memberTotal = money(m.publicTotal.minor - m.saved.minor, cur);
2145
+ const atHotel = !q.lines.some((l) => l.payment === 'prepaid');
2146
+ const stays = new Set(q.lines.flatMap((l) => (l.kind === 'room' ? [`${l.hotelId}|${l.stay?.checkIn}`] : []))).size;
2147
+ return [
2148
+ `${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
2149
+ ? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
2150
+ : ''} → **${atHotel ? 'to pay at the hotel' : 'to pay'} ${formatMoney(q.total)}**. Earns about ${m.points.toLocaleString('en-GB')} points${q.coupon ? ', on the price after the coupon' : ''}.`,
2151
+ q.couponRefused
2152
+ ? `The coupon ${q.couponRefused.name ? couponName({ name: q.couponRefused.name, names: q.couponRefused.names }) : q.couponRefused.id} was NOT taken off: ${q.couponRefused.why} The total is without it.`
2153
+ : '',
2154
+ ...(q.coupons?.length
2155
+ ? [
2156
+ `The member's coupons for this trip - ${COUPON_RULE}`,
2157
+ ...q.coupons.map((c) => `- ${couponName(c)} (\`${c.id}\`): ${q.coupon?.id === c.id
2158
+ ? 'taken off above'
2159
+ : c.off
2160
+ ? `would take ${formatMoney(c.off)} off${stays > 1 && c.hotelName ? ` (the stay at ${c.hotelName})` : ''}`
2161
+ : `cannot be used here: ${c.why ?? 'it does not apply.'}`}`),
2162
+ ]
2163
+ : []),
2164
+ ].filter(Boolean);
2165
+ };
1933
2166
  const tripText = (quote, demo) => {
1934
2167
  if (quote.lines.length === 0)
1935
2168
  return 'The trip is empty.';
@@ -1938,7 +2171,8 @@ const tripText = (quote, demo) => {
1938
2171
  : `- ${l.ordered ?? l.quantity}× ${l.title}${l.ordered !== undefined && l.quantity !== undefined && l.quantity !== l.ordered ? ` (${l.quantity} in all, over the stay)` : ''} — ${formatMoney(l.total)}`);
1939
2172
  return [
1940
2173
  ...lines,
1941
- `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
2174
+ `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
2175
+ ...memberTripLines(quote),
1942
2176
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
1943
2177
  demo ? DEMO_NOTE : '',
1944
2178
  ]
@@ -2057,26 +2291,15 @@ const bare = (kind) => REQUEST_WORDS[kind].replace(/^an? /, '');
2057
2291
  * desk cannot act on.
2058
2292
  */
2059
2293
  const SORTS = ['group', 'price-low', 'price-high', 'rating'];
2060
- /**
2061
- * The ONE wider search to try when the narrow one found nothing, and how to
2062
- * describe it.
2063
- *
2064
- * ⚠️ IT WIDENS ONCE AND STOPS. A search that keeps loosening until something
2065
- * comes back always finds something, and what it finds is unrelated to what was
2066
- * asked - which is worse than "no", because "no" is at least true. One step,
2067
- * and the answer names it.
2068
- *
2069
- * ⚠️ AND IT DROPS THE WORDS BEFORE THE PLACE. "Nothing in Tokyo matches
2070
- * «rooftop pool»" still leaves a guest looking at Tokyo, which is where they
2071
- * said they were going; keeping the words and losing Tokyo answers a question
2072
- * nobody asked.
2073
- */
2074
2294
  const widen = (ask) => {
2075
2295
  if (ask.city && ask.query) {
2076
2296
  return {
2077
2297
  ask: { ...ask, query: undefined },
2078
2298
  from: `${ask.city} and "${ask.query}"`,
2079
2299
  to: ask.city,
2300
+ how: 'words',
2301
+ city: ask.city,
2302
+ query: ask.query,
2080
2303
  };
2081
2304
  }
2082
2305
  // A place nobody has filed under that name may still be in a hotel's own
@@ -2086,6 +2309,8 @@ const widen = (ask) => {
2086
2309
  ask: { ...ask, city: undefined, query: ask.city },
2087
2310
  from: `the city "${ask.city}"`,
2088
2311
  to: `"${ask.city}" anywhere in the record`,
2312
+ how: 'place',
2313
+ city: ask.city,
2089
2314
  };
2090
2315
  }
2091
2316
  // Several words that match nothing together may match on the longest one.
@@ -2096,6 +2321,9 @@ const widen = (ask) => {
2096
2321
  ask: { ...ask, query: longest },
2097
2322
  from: `all of "${ask.query}"`,
2098
2323
  to: `"${longest}"`,
2324
+ how: 'word',
2325
+ query: ask.query ?? '',
2326
+ word: longest,
2099
2327
  };
2100
2328
  }
2101
2329
  return undefined;
@@ -2344,6 +2572,13 @@ const bookingLine = (b) => {
2344
2572
  ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
2345
2573
  : b.status}`,
2346
2574
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
2575
+ b.quote.coupon
2576
+ ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
2577
+ ? ' (given back)'
2578
+ : b.couponReturned === false
2579
+ ? ' (ran out - not given back)'
2580
+ : ''}`
2581
+ : '',
2347
2582
  b.payment
2348
2583
  ? ` · ${paymentWords(b)}${b.payment.status === 'awaiting' && b.payment.url ? ` by ${b.payment.payByLocal ?? b.payment.payBy}: ${b.payment.url}` : ''}`
2349
2584
  : '',
@@ -2639,15 +2874,38 @@ const guarded = async (deps, ctx, who, code, run) => {
2639
2874
  * read as the hotel's. Now it is two quotes, drawn side by side and never
2640
2875
  * added up; each hotel is booked, and paid, in its own.
2641
2876
  */
2642
- const quoteParts = async (deps, lines, who) => {
2877
+ const quoteParts = async (deps, lines, who, coupon) => {
2643
2878
  const byCurrency = new Map();
2644
2879
  for (const l of lines) {
2645
2880
  const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
2646
2881
  byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
2647
2882
  }
2883
+ const asked = coupon ? { coupon } : {};
2648
2884
  if (byCurrency.size === 0)
2649
- return [await deps.backend.quote({ lines: [] }, who)];
2650
- return Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g }, who)));
2885
+ return [await deps.backend.quote({ lines: [], ...asked }, who)];
2886
+ const parts = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
2887
+ // A coupon goes on one part of a trip in two currencies: the others need
2888
+ // not say it is in the wrong one.
2889
+ return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
2890
+ };
2891
+ /** The coupon a call names - only where the backend takes coupons (`capabilities.coupons`). */
2892
+ const couponOf = (deps, args) => (deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
2893
+ ? str(args.coupon) || undefined
2894
+ : undefined;
2895
+ /**
2896
+ * The signed-in member as the backend has them now - their coupons spent or
2897
+ * handed back since they signed in. Every answer's badge reads the store, so
2898
+ * the card's member page is never a booking behind.
2899
+ */
2900
+ const refreshMember = async (deps, key) => {
2901
+ if (!(deps.backend.capabilities ?? FULL_CAPABILITIES).coupons)
2902
+ return;
2903
+ const m = deps.members.read(key);
2904
+ if (!m)
2905
+ return;
2906
+ const now = await deps.backend.signInMember(m.number, m.surname);
2907
+ if (now)
2908
+ deps.members.set(key, { ...now, ...(m.preferences ? { preferences: m.preferences } : {}) });
2651
2909
  };
2652
2910
  /** The trip, priced, as both views need it. */
2653
2911
  /**
@@ -2688,9 +2946,11 @@ const needsRoomIn = (t, lines, parts) => {
2688
2946
  * `store`) the picked room's slot. `key` stays the conversation's trip key: it
2689
2947
  * is the one `common` reads, so a header's trip count never counts a pick.
2690
2948
  */
2691
- const currentTrip = async (deps, key, who, store = key) => {
2949
+ const currentTrip = async (deps, key, who, store = key, coupon) => {
2950
+ await refreshMember(deps, key);
2951
+ const member = deps.members.read(key);
2692
2952
  const lines = deps.trips.read(store);
2693
- const parts = await quoteParts(deps, lines, who);
2953
+ const parts = await quoteParts(deps, lines, who && member ? { ...who, member } : who, coupon);
2694
2954
  // ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
2695
2955
  // priced trip answer goes through here: add_to_trip, view_trip, and the
2696
2956
  // card's own trip view.
@@ -3449,7 +3709,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3449
3709
  : !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
3450
3710
  if (held) {
3451
3711
  deps.trips.set(store, unset(held));
3452
- return currentTrip(deps, key, who, store);
3712
+ return currentTrip(deps, key, who, store, couponOf(deps, args));
3453
3713
  }
3454
3714
  }
3455
3715
  const today = (startTime || !asStay(args)
@@ -3509,7 +3769,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3509
3769
  hours: hourlyRoom.hourly.hours,
3510
3770
  },
3511
3771
  });
3512
- return currentTrip(deps, key, who, store);
3772
+ return currentTrip(deps, key, who, store, couponOf(deps, args));
3513
3773
  }
3514
3774
  const stay = asStay(args);
3515
3775
  // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
@@ -3655,7 +3915,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3655
3915
  stay,
3656
3916
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3657
3917
  });
3658
- const now = await currentTrip(deps, key, identity(deps, ctx, key), store);
3918
+ const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
3659
3919
  // Which rate was taken, in the structure too and in the same words - see
3660
3920
  // the itinerary's `nudges` (2026-10-27 audit, F11).
3661
3921
  return took
@@ -3742,7 +4002,14 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3742
4002
  const { requests, dropped } = readRequests(args);
3743
4003
  try {
3744
4004
  const shown = deps.trips.shown(store);
3745
- const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
4005
+ const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking(
4006
+ // The coupon the guest named, spent on the one stay it goes on - or
4007
+ // nothing is booked (see `TripDraft.coupon`).
4008
+ {
4009
+ lines,
4010
+ ...(shown ? { shown } : {}),
4011
+ ...(couponOf(deps, args) ? { coupon: couponOf(deps, args) } : {}),
4012
+ }, {
3746
4013
  ...guest,
3747
4014
  ...(str(args.phone) ? { phone: str(args.phone) } : {}),
3748
4015
  ...(requests?.note ? { notes: requests.note } : {}),
@@ -3756,6 +4023,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3756
4023
  // only place an Identity is built; the guest's email still overrides,
3757
4024
  // because a booking is found by the address it was made with.
3758
4025
  { ...identity(deps, ctx, key), email: guest.email }, requests);
4026
+ // A coupon spent is spent on the member's badge too.
4027
+ await refreshMember(deps, key);
3759
4028
  // ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
3760
4029
  // and all - to ask again, or to put another hotel in its place (robin,
3761
4030
  // 2026-10-14).
@@ -3962,6 +4231,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3962
4231
  const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3963
4232
  return { ...again, text: [words, again.text].join('\n\n') };
3964
4233
  }
4234
+ // The coupon asked for cannot be used: nothing was booked, and the trip
4235
+ // is shown again with it refused and why - the guest decides what next.
4236
+ if (err.code === 'coupon') {
4237
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4238
+ return {
4239
+ ...again,
4240
+ structured: { ...again.structured, notBooked: 'coupon' },
4241
+ text: [words, again.text].join('\n\n'),
4242
+ };
4243
+ }
3965
4244
  if (err.code === 'mixed-supplier') {
3966
4245
  const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3967
4246
  return {
@@ -4070,7 +4349,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4070
4349
  // Found when the wider PLACE matched - a wider search emptied by the
4071
4350
  // same ceiling or filter is still the place, and "Hong Kong found
4072
4351
  // nothing either" was said of four hotels over the ceiling.
4073
- widened = { from: wider.from, to: wider.to, found: placedCount(second) > 0 };
4352
+ const { ask: _wider, ...said } = wider;
4353
+ widened = { ...said, found: placedCount(second) > 0 };
4074
4354
  if (widened.found) {
4075
4355
  answer = second;
4076
4356
  asked = { ...ask, ...wider.ask };
@@ -4149,12 +4429,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4149
4429
  ...(ask.rooms && ask.rooms > 1 ? { rooms: ask.rooms } : {}),
4150
4430
  }
4151
4431
  : {};
4432
+ // How many the whole deployment has: the server says, and an unnarrowed
4433
+ // answer is it. A deployment of up to HOME_LIST_MAX fits on one page -
4434
+ // never a market, whose home is its groups.
4435
+ const unnarrowed = !ask.query && !ask.city && !ask.filters && !ask.maxNightly;
4436
+ const deployed = deps.hotelCount?.() ?? (unnarrowed ? answer.total : 0);
4437
+ const fitsOnePage = !deps.backend.suppliers && !deps.vocab.market && deployed >= 1 && deployed <= HOME_LIST_MAX;
4152
4438
  // ⚠️ THE SEARCH AS ASKED, `all` INCLUDED. The echo dropped `all`, so every
4153
4439
  // re-query the card made from a "Browse all" list - new dates, the default
4154
4440
  // order, clearing a filter - met no place, no words and no filter, and
4155
4441
  // came back as the destinations page (2026-09-28 audit). Built once, for
4156
4442
  // the list and for the list a filter or a ceiling emptied.
4157
- const listAll = args.all === true;
4443
+ //
4444
+ // ⚠️ WHERE IT ALL FITS ON ONE PAGE, `all` IS THE HOME (robin, 2026-10-30:
4445
+ // 「华住默认还是没有日期呀」). Huazhu's production has two; asked to open
4446
+ // Huazhu, the model sent `all: true` - "lists the hotels themselves" - and
4447
+ // that skipped the home: no tonight, no prices, "All hotels · add dates".
4448
+ // Here every hotel is on the home already, so `all` asks for nothing more.
4449
+ const listAll = args.all === true && !fitsOnePage;
4158
4450
  const echo = (applied) => ({
4159
4451
  ...(asked.query ? { query: asked.query } : {}),
4160
4452
  ...(asked.city ? { city: asked.city } : {}),
@@ -4199,17 +4491,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4199
4491
  // theirs read as that night's prices; here the dates and the prices are
4200
4492
  // the same night, so that cannot happen. Tonight is the hotels' own date
4201
4493
  // (the earliest of them, `todayThere`), for the default party; asked again
4202
- // with those nights, the answer is the home as before - priced. Only the
4203
- // home of a deployment that fits on one page (the list of all, or the one
4204
- // hotel's page): a group of many still chooses a city first, a market its
4205
- // group, and dates or a place the model gave are used as given.
4206
- const homeOfFew = !!choosing &&
4207
- !asked.city &&
4208
- !deps.backend.suppliers &&
4209
- total >= 1 &&
4210
- total <= HOME_LIST_MAX &&
4211
- hotels.length === total;
4212
- if (homeOfFew && !ask.stay && today?.min) {
4494
+ // with those nights, the answer is the home as before - priced.
4495
+ //
4496
+ // ⚠️ AND EVERY LIST THERE WITHOUT DATES, NOT THE HOME ALONE (0.51.1). In a
4497
+ // deployment that fits on one page a list - a city, words, a filter, `all`
4498
+ // - is the home or a part of it: without dates it was the same hotels with
4499
+ // tonight's prices taken away, "add dates to see what a stay costs" one
4500
+ // turn after the home showed them. It costs what the home costs, a quote
4501
+ // per hotel (six at most). A group of many still chooses a city first and
4502
+ // lists twenty at a time without prices, a market its group; rooms by the
4503
+ // hour are today's by their own rule; dates the model gave are used as given.
4504
+ if (fitsOnePage && !ask.stay && !ask.filters?.hourly && hotels.length && today?.min) {
4213
4505
  const tonight = {
4214
4506
  checkIn: today.min,
4215
4507
  checkOut: addDays(today.min, 1),
@@ -4657,6 +4949,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4657
4949
  : {}),
4658
4950
  ...(r.freeCancellationUntil ? { freeCancellationLocal: freeUntilText(r) } : {}),
4659
4951
  }));
4952
+ const memberRates = memberRatesOf(enough, roomsWanted);
4660
4953
  return {
4661
4954
  structured: {
4662
4955
  view: 'hotel',
@@ -4669,9 +4962,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4669
4962
  // Types with no room to book for these nights, said and not left out.
4670
4963
  ...(byTheHour || !soldOut.length ? {} : { soldOut }),
4671
4964
  ...(notSold ? { notSoldHere: notSold } : {}),
4965
+ ...(memberRates ? { memberRates } : {}),
4672
4966
  ...flex.structured,
4673
4967
  },
4674
4968
  text: [
4969
+ memberRates?.said ?? '',
4675
4970
  ...roomsText(h.name, byTheHour
4676
4971
  ? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
4677
4972
  : (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),
@@ -4955,7 +5250,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4955
5250
  return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
4956
5251
  }
4957
5252
  if (name === t.viewTrip)
4958
- return currentTrip(deps, key, identity(deps, ctx, key));
5253
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
4959
5254
  if (name === t.clearTrip) {
4960
5255
  deps.trips.clear(key);
4961
5256
  return {
@@ -5367,10 +5662,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5367
5662
  }
5368
5663
  : {}),
5369
5664
  toolToConfirm: t.cancelBooking,
5665
+ // Whether cancelling hands the coupon back - the same sentence as the text.
5666
+ ...(preview.coupon
5667
+ ? { coupon: { ...preview.coupon, said: couponBackWords(preview.coupon) } }
5668
+ : {}),
5370
5669
  },
5371
5670
  text: [
5372
5671
  `Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
5373
5672
  preview.rule,
5673
+ preview.coupon ? couponBackWords(preview.coupon) : '',
5374
5674
  preview.cancellable
5375
5675
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
5376
5676
  : '',
@@ -5405,6 +5705,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5405
5705
  const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
5406
5706
  if (!cancelled)
5407
5707
  return asPreview;
5708
+ // A coupon handed back is back on the member's badge.
5709
+ await refreshMember(deps, key);
5408
5710
  // ⚠️ WHAT THE HOTEL SAYS IT CHARGED, once it has said. A group's own system
5409
5711
  // answers a cancellation with its penalty; the preview was our reading of
5410
5712
  // the rate's terms, and where the two differ the hotel's is the one that
@@ -5435,6 +5737,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5435
5737
  ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
5436
5738
  `${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.`}`
5437
5739
  : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
5740
+ cancelled.quote.coupon && cancelled.couponReturned !== undefined
5741
+ ? cancelled.couponReturned
5742
+ ? `The coupon ${couponName(cancelled.quote.coupon)} is back with the member, usable until ${cancelled.quote.coupon.validUntil}.`
5743
+ : `The coupon ${couponName(cancelled.quote.coupon)} ran out on ${cancelled.quote.coupon.validUntil}, so it is not given back.`
5744
+ : '',
5438
5745
  demoBookingNote(deps, cancelled.supplier),
5439
5746
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5440
5747
  ]
@@ -5652,17 +5959,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5652
5959
  }
5653
5960
  deps.members.set(key, member);
5654
5961
  return {
5655
- structured: { view: 'member', ...common(deps, key), member },
5962
+ structured: {
5963
+ view: 'member',
5964
+ ...common(deps, key),
5965
+ member: memberForCard(member, deps.vocab.brand),
5966
+ },
5656
5967
  text: [
5657
- `Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
5968
+ `Signed in as ${member.name}${member.names?.zh ? ` (${member.names.zh})` : ''} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
5658
5969
  `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
5970
+ ...((deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
5971
+ ? memberSaid(member, deps.vocab.brand)
5972
+ : [POINTS_DO_NOT_PAY]),
5659
5973
  member.preferences?.length
5660
5974
  ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
5661
5975
  : '',
5662
5976
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5663
5977
  ]
5664
5978
  .filter(Boolean)
5665
- .join(' '),
5979
+ .join('\n'),
5666
5980
  };
5667
5981
  }
5668
5982
  if (name === t.signOut) {
@@ -5688,7 +6002,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5688
6002
  deps.members.set(key, member);
5689
6003
  deps.members.setPreferences(m.number, []);
5690
6004
  return {
5691
- structured: { view: 'member', ...common(deps, key), member },
6005
+ structured: {
6006
+ view: 'member',
6007
+ ...common(deps, key),
6008
+ member: memberForCard(member, deps.vocab.brand),
6009
+ },
5692
6010
  text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
5693
6011
  };
5694
6012
  }