@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/aggregate/hotels.d.ts.map +1 -1
- package/dist/aggregate/hotels.js +2 -1
- package/dist/aggregate/hotels.js.map +1 -1
- package/dist/backend.d.ts +6 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +161 -0
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +48 -2
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +364 -11
- package/dist/demo-hotels.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -2
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +9 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +365 -47
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +150 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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
|
|
109
|
-
|
|
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
|
-
? {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4203
|
-
//
|
|
4204
|
-
//
|
|
4205
|
-
//
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
if (
|
|
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: {
|
|
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: {
|
|
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
|
}
|