@curless/hotel-storefront 0.50.4 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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.js +2 -2
- 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.map +1 -1
- package/dist/tools.js +348 -20
- 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.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,mBAAmB,EASzB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAa,YAAY,EAAgB,MAAM,cAAc,CAAC;AAC1E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAK7C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAiChD,OAAO,EAGL,KAAK,SAAS,EAMf,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAEV,cAAc,
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
|
package/dist/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
|
|
2
|
-
import { MAX_GUESTS, isDay, nights as nightsBetween } from './dates.js';
|
|
2
|
+
import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
|
|
3
3
|
import { FACILITIES } from './facilities.js';
|
|
4
4
|
import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
|
|
5
5
|
import { clockOf, minutesOf, startProblem, stayWords, windowWords } from './hourly.js';
|
|
@@ -106,8 +106,8 @@ export const homeListing = (t, brand, count) => {
|
|
|
106
106
|
if (t.name !== brand.tools.listHotels || v.market || count < 1 || count > HOME_LIST_MAX)
|
|
107
107
|
return t;
|
|
108
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
|
|
110
|
-
: `This deployment has ${count} ${v.nounPlural} in all: asked with no place, it lists every one of them
|
|
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. `;
|
|
111
111
|
const places = /Asked with no place[\s\S]*?themselves\. /;
|
|
112
112
|
return {
|
|
113
113
|
...t,
|
|
@@ -200,6 +200,44 @@ const forCapabilities = (tools, n, v, caps) => {
|
|
|
200
200
|
}),
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
|
+
// ⚠️ ONLY WHERE COUPONS ARE TAKEN (`capabilities.coupons`): a group that
|
|
204
|
+
// takes none is offered no parameter for one.
|
|
205
|
+
if (caps.coupons) {
|
|
206
|
+
const withCoupon = (schema, description) => {
|
|
207
|
+
const s = schema;
|
|
208
|
+
return {
|
|
209
|
+
...s,
|
|
210
|
+
properties: { ...(s.properties ?? {}), coupon: { type: 'string', description } },
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
if (t.name === n.viewTrip)
|
|
214
|
+
out = {
|
|
215
|
+
...out,
|
|
216
|
+
description: `${out.description} With \`coupon\`, priced with that coupon taken off, after the member rate - nothing is spent by looking.`,
|
|
217
|
+
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'),
|
|
218
|
+
};
|
|
219
|
+
if (t.name === n.requestBooking)
|
|
220
|
+
out = {
|
|
221
|
+
...out,
|
|
222
|
+
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'),
|
|
223
|
+
};
|
|
224
|
+
// The card's stepper and Remove on a trip priced with a coupon keep it.
|
|
225
|
+
if (t.name === n.addToTrip)
|
|
226
|
+
out = {
|
|
227
|
+
...out,
|
|
228
|
+
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'),
|
|
229
|
+
};
|
|
230
|
+
if (t.name === n.pickRoom || t.name === n.bookPicked)
|
|
231
|
+
out = {
|
|
232
|
+
...out,
|
|
233
|
+
inputSchema: withCoupon(out.inputSchema, 'The coupon the guest chose on the card, by its id'),
|
|
234
|
+
};
|
|
235
|
+
if (t.name === n.signIn)
|
|
236
|
+
out = {
|
|
237
|
+
...out,
|
|
238
|
+
description: out.description.replace('and answers with their tier, their point balance', 'and answers with their tier, their point balance, the coupons they hold'),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
203
241
|
// ⚠️ THE PICKED ROOM'S BOOKING TAKES THE SAME GUEST FIELDS as the trip's:
|
|
204
242
|
// a phone where the hotel needs one, and no `rememberRequests` without
|
|
205
243
|
// members. Its description is its own (below, only request_booking's).
|
|
@@ -995,6 +1033,10 @@ const TRIP_SCHEMA = {
|
|
|
995
1033
|
member: OBJ,
|
|
996
1034
|
/** What the tier took off THIS trip, and what it earns. */
|
|
997
1035
|
memberSaving: OBJ,
|
|
1036
|
+
/** The coupon taken off, the one asked for and refused, and every one the member holds. */
|
|
1037
|
+
coupon: OBJ,
|
|
1038
|
+
couponRefused: OBJ,
|
|
1039
|
+
coupons: ARR,
|
|
998
1040
|
days: ARR,
|
|
999
1041
|
dropped: ARR,
|
|
1000
1042
|
/** A name that fits more than one hotel: the ones it fits (`namesLike`). */
|
|
@@ -1008,6 +1050,8 @@ const CANCEL_SCHEMA = {
|
|
|
1008
1050
|
type: 'object',
|
|
1009
1051
|
properties: {
|
|
1010
1052
|
view: { enum: ['cancellation-preview', 'cancelled', 'booking-not-found'] },
|
|
1053
|
+
/** The coupon the booking used and whether cancelling gives it back. */
|
|
1054
|
+
coupon: OBJ,
|
|
1011
1055
|
brand: STR,
|
|
1012
1056
|
toolNames: OBJ,
|
|
1013
1057
|
bookingMode: STR,
|
|
@@ -1140,6 +1184,8 @@ const HOTEL_PAGE = {
|
|
|
1140
1184
|
needsDates: BOOL,
|
|
1141
1185
|
/** How many rooms of one type the list was filtered to. */
|
|
1142
1186
|
rooms_wanted: { type: 'number' },
|
|
1187
|
+
/** Signed in: whose rate the prices are, said as the text says it (`said`). */
|
|
1188
|
+
memberRates: OBJ,
|
|
1143
1189
|
noRooms: STR,
|
|
1144
1190
|
/** `noRooms` as a code (`NoRoomsCode`) and what it holds, for a card to say in the guest's language. */
|
|
1145
1191
|
noRoomsCode: STR,
|
|
@@ -1183,6 +1229,11 @@ const BOOKING_SCHEMA = {
|
|
|
1183
1229
|
// `trip`: nothing was booked and the trip is shown again - its price
|
|
1184
1230
|
// moved, or (in a market) it holds two groups' stays.
|
|
1185
1231
|
view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error', 'trip'] },
|
|
1232
|
+
/** `coupon`: nothing was booked because the coupon asked for cannot be used (`couponRefused`). */
|
|
1233
|
+
notBooked: STR,
|
|
1234
|
+
coupon: OBJ,
|
|
1235
|
+
couponRefused: OBJ,
|
|
1236
|
+
coupons: ARR,
|
|
1186
1237
|
brand: STR,
|
|
1187
1238
|
toolNames: OBJ,
|
|
1188
1239
|
bookingMode: STR,
|
|
@@ -1262,6 +1313,8 @@ export const buildOutputSchemas = (n) => ({
|
|
|
1262
1313
|
home: BOOL,
|
|
1263
1314
|
/** With `only` or `home`: every hotel there is, and that there are no others - as the text says it. */
|
|
1264
1315
|
inAll: STR,
|
|
1316
|
+
/** With `only` or `home` and no dates asked: priced for tonight by default, and that it can change - as the text says it. */
|
|
1317
|
+
stayDefault: STR,
|
|
1265
1318
|
/** Only where the client shows no cards: nothing was drawn - see `LIST_NOT_DRAWN`. */
|
|
1266
1319
|
notDrawn: STR,
|
|
1267
1320
|
...HOTEL_PAGE,
|
|
@@ -1517,10 +1570,25 @@ const memberBadge = (deps, key) => {
|
|
|
1517
1570
|
programme: m.programme,
|
|
1518
1571
|
tier: { name: m.tier.name, discount: m.tier.discount },
|
|
1519
1572
|
...(m.preferences?.length ? { preferences: m.preferences } : {}),
|
|
1573
|
+
// As they stand now - spent or handed back since signing in - so
|
|
1574
|
+
// the member page the badge opens is never a booking behind.
|
|
1575
|
+
points: m.points,
|
|
1576
|
+
...(m.pointsExpiring ? { pointsExpiring: m.pointsExpiring } : {}),
|
|
1577
|
+
...(m.coupons ? { coupons: m.coupons.map(couponForCard) } : {}),
|
|
1520
1578
|
},
|
|
1521
1579
|
}
|
|
1522
1580
|
: { signedIn: false };
|
|
1523
1581
|
};
|
|
1582
|
+
/** A member for the card: every amount as text, like every amount the card is given. */
|
|
1583
|
+
const memberForCard = (m, brand) => ({
|
|
1584
|
+
...m,
|
|
1585
|
+
// Each with its terms in the sign-in text's own words (\`said\`): the model
|
|
1586
|
+
// reads this, and a coupon for every hotel had no scope here at all - so
|
|
1587
|
+
// "全部门店" went unsaid (0.51.0 eval).
|
|
1588
|
+
...(m.coupons
|
|
1589
|
+
? { coupons: m.coupons.map((c) => ({ ...couponForCard(c), said: couponTerms(c, brand) })) }
|
|
1590
|
+
: {}),
|
|
1591
|
+
});
|
|
1524
1592
|
/** A market's group by its id or its name, as a guest or a model would say it. */
|
|
1525
1593
|
/** How many stays that failed are each given alternatives - each is a search. */
|
|
1526
1594
|
const MAX_FAILED_WITH_ALTERNATIVES = 3;
|
|
@@ -1777,7 +1845,22 @@ const madeForCard = (deps, b) => {
|
|
|
1777
1845
|
const { confirmationCode: _unconfirmed, ...rest } = card;
|
|
1778
1846
|
return { ...rest, pending: true };
|
|
1779
1847
|
};
|
|
1780
|
-
const bookedLine = (deps, b) =>
|
|
1848
|
+
const bookedLine = (deps, b) => [heldLine(deps, b), couponPointsLine(b)].filter(Boolean).join(' ');
|
|
1849
|
+
/**
|
|
1850
|
+
* The coupon a booking used and the points it should earn - on the booking
|
|
1851
|
+
* itself, so the confirmation says what the review said (§4, §5).
|
|
1852
|
+
*/
|
|
1853
|
+
const couponPointsLine = (b) => [
|
|
1854
|
+
b.quote.coupon
|
|
1855
|
+
? `Coupon ${couponName(b.quote.coupon)} used: −${formatMoney(b.quote.coupon.off)}, and ${formatMoney(b.quote.total)} is the booking's total after it.`
|
|
1856
|
+
: '',
|
|
1857
|
+
b.quote.member
|
|
1858
|
+
? `About ${b.quote.member.points.toLocaleString('en-GB')} ${b.quote.member.programme} points after the stay - not added to the balance here.`
|
|
1859
|
+
: '',
|
|
1860
|
+
]
|
|
1861
|
+
.filter(Boolean)
|
|
1862
|
+
.join(' ');
|
|
1863
|
+
const heldLine = (deps, b) => {
|
|
1781
1864
|
const paid = paidLine(deps, b);
|
|
1782
1865
|
if (paid)
|
|
1783
1866
|
return paid;
|
|
@@ -1873,6 +1956,35 @@ const langOf = (deps, _key) => deps.lang;
|
|
|
1873
1956
|
*/
|
|
1874
1957
|
const freeUntilText = (r) => r.freeCancellationLocal ??
|
|
1875
1958
|
`${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
|
|
1959
|
+
/**
|
|
1960
|
+
* What a signed-in member's prices are, said once at the top of a hotel's
|
|
1961
|
+
* rooms - with the cheapest one's public and member figures, so "member rate"
|
|
1962
|
+
* is a number the guest can check (10-30: the rooms were at the member rate
|
|
1963
|
+
* and nothing told the model so).
|
|
1964
|
+
*/
|
|
1965
|
+
const memberRatesOf = (rooms, roomsWanted) => {
|
|
1966
|
+
const priced = rooms.filter((r) => r.memberRate && r.publicStayTotal);
|
|
1967
|
+
const cheapest = priced.reduce((a, r) => (!a || r.stayTotal.minor < a.stayTotal.minor ? r : a), undefined);
|
|
1968
|
+
const m = cheapest?.memberRate;
|
|
1969
|
+
if (!cheapest?.publicStayTotal || !m)
|
|
1970
|
+
return undefined;
|
|
1971
|
+
const n = Math.max(1, roomsWanted);
|
|
1972
|
+
return {
|
|
1973
|
+
programme: m.programme,
|
|
1974
|
+
tier: m.tier,
|
|
1975
|
+
discount: m.discount,
|
|
1976
|
+
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)` : ''}.`,
|
|
1977
|
+
};
|
|
1978
|
+
};
|
|
1979
|
+
/** A held coupon for the card: its amounts as text, like every amount the card is given. */
|
|
1980
|
+
const couponForCard = (c) => ({
|
|
1981
|
+
...c,
|
|
1982
|
+
...(c.amountOff ? { amountOff: formatMoney(c.amountOff) } : {}),
|
|
1983
|
+
...(c.maxOff ? { maxOff: formatMoney(c.maxOff) } : {}),
|
|
1984
|
+
...(c.minSpend ? { minSpend: formatMoney(c.minSpend) } : {}),
|
|
1985
|
+
...(c.off ? { off: formatMoney(c.off) } : {}),
|
|
1986
|
+
...(c.spend ? { spend: formatMoney(c.spend) } : {}),
|
|
1987
|
+
});
|
|
1876
1988
|
/** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
|
|
1877
1989
|
const quoteForCard = (quote, groupOf) => ({
|
|
1878
1990
|
lines: quote.lines.map((l) => ({
|
|
@@ -1923,11 +2035,114 @@ const quoteForCard = (quote, groupOf) => ({
|
|
|
1923
2035
|
// the quote's block is a different shape (a tier NAME, a saving, points).
|
|
1924
2036
|
// Two shapes on one key is how a card reads `member.tier.name` off a string.
|
|
1925
2037
|
...(quote.member
|
|
1926
|
-
? {
|
|
2038
|
+
? {
|
|
2039
|
+
memberSaving: {
|
|
2040
|
+
...quote.member,
|
|
2041
|
+
saved: formatMoney(quote.member.saved),
|
|
2042
|
+
// The review's own order - public, member, coupon, total - each
|
|
2043
|
+
// figure the server's, so the card adds nothing up (10-30, ①).
|
|
2044
|
+
publicTotal: formatMoney(quote.member.publicTotal),
|
|
2045
|
+
memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
|
|
2046
|
+
},
|
|
2047
|
+
}
|
|
1927
2048
|
: {}),
|
|
2049
|
+
...(quote.coupon ? { coupon: { ...quote.coupon, off: formatMoney(quote.coupon.off) } } : {}),
|
|
2050
|
+
...(quote.couponRefused
|
|
2051
|
+
? {
|
|
2052
|
+
couponRefused: {
|
|
2053
|
+
...quote.couponRefused,
|
|
2054
|
+
...(quote.couponRefused.minSpend
|
|
2055
|
+
? { minSpend: formatMoney(quote.couponRefused.minSpend) }
|
|
2056
|
+
: {}),
|
|
2057
|
+
...(quote.couponRefused.spend ? { spend: formatMoney(quote.couponRefused.spend) } : {}),
|
|
2058
|
+
},
|
|
2059
|
+
}
|
|
2060
|
+
: {}),
|
|
2061
|
+
...(quote.coupons ? { coupons: quote.coupons.map(couponForCard) } : {}),
|
|
1928
2062
|
...(quote.stay ? { stay: quote.stay } : {}),
|
|
1929
2063
|
...(quote.dropped ? { dropped: quote.dropped } : {}),
|
|
1930
2064
|
});
|
|
2065
|
+
/**
|
|
2066
|
+
* A coupon's name as a guest of either language reads it: the programme's
|
|
2067
|
+
* Chinese beside its English - "满500减50 (¥50 off a stay of ¥500)".
|
|
2068
|
+
*/
|
|
2069
|
+
const couponName = (c) => c.names?.zh && c.names.zh !== c.name ? `${c.names.zh} (${c.name})` : c.name;
|
|
2070
|
+
/** "A, B or C". */
|
|
2071
|
+
const listOrWords = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} or ${xs.at(-1)}` : (xs[0] ?? '');
|
|
2072
|
+
/** What a held coupon is - what it takes off, where, until when - and where it stands. */
|
|
2073
|
+
const couponTerms = (c, brand) => [
|
|
2074
|
+
c.kind === 'amount-off'
|
|
2075
|
+
? `${c.amountOff ? formatMoney(c.amountOff) : ''} off${c.minSpend ? ` a stay of ${formatMoney(c.minSpend)} or more` : ', no minimum'}`
|
|
2076
|
+
: `${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` : ''}`,
|
|
2077
|
+
c.hotels?.length
|
|
2078
|
+
? `only at ${listOrWords(c.hotels.map((h) => h.name))}`
|
|
2079
|
+
: c.brands?.length
|
|
2080
|
+
? `only at ${listOrWords(c.brands.map((b) => b.name))} hotels`
|
|
2081
|
+
: `at every ${brand} hotel`,
|
|
2082
|
+
c.state === 'expired'
|
|
2083
|
+
? `ran out on ${c.validUntil} - cannot be used`
|
|
2084
|
+
: c.state === 'used'
|
|
2085
|
+
? 'already used on a booking'
|
|
2086
|
+
: c.state === 'not-yet'
|
|
2087
|
+
? `usable from ${c.validFrom} to ${c.validUntil}`
|
|
2088
|
+
: `until ${c.validUntil}`,
|
|
2089
|
+
].join(' · ');
|
|
2090
|
+
/** What cancelling does to the coupon a booking used. */
|
|
2091
|
+
const couponBackWords = (c) => c.returned
|
|
2092
|
+
? `The coupon ${couponName(c)} it used comes back to the member on cancelling (usable until ${c.validUntil}).`
|
|
2093
|
+
: `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
|
|
2094
|
+
/**
|
|
2095
|
+
* Said wherever points are: they are shown and earned, never spent here -
|
|
2096
|
+
* nobody has published what a point is worth (hotel-connectors.md §5).
|
|
2097
|
+
*/
|
|
2098
|
+
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.';
|
|
2099
|
+
/** The rule a coupon is used by, said once where coupons are listed. */
|
|
2100
|
+
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.';
|
|
2101
|
+
/** A signed-in member's coupons and points, for the sign-in answer - see `memberCouponsSaid`. */
|
|
2102
|
+
const memberSaid = (m, brand) => [
|
|
2103
|
+
`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}`,
|
|
2104
|
+
...(m.coupons
|
|
2105
|
+
? m.coupons.length
|
|
2106
|
+
? [
|
|
2107
|
+
`Coupons held (${m.coupons.length}): ${COUPON_RULE}`,
|
|
2108
|
+
// The terms say it in English; the name is the programme's own.
|
|
2109
|
+
...m.coupons.map((c) => `- ${c.names?.zh ?? c.name} · ${couponTerms(c, brand)} · id \`${c.id}\``),
|
|
2110
|
+
]
|
|
2111
|
+
: ['No coupons are held on this membership.']
|
|
2112
|
+
: []),
|
|
2113
|
+
];
|
|
2114
|
+
/**
|
|
2115
|
+
* What membership did to this trip, in the review's own order: the public
|
|
2116
|
+
* rate, the member rate, the coupon, what is paid - the same three figures the
|
|
2117
|
+
* card shows (`memberSaving`), so text and structure agree.
|
|
2118
|
+
*/
|
|
2119
|
+
const memberTripLines = (q) => {
|
|
2120
|
+
const m = q.member;
|
|
2121
|
+
if (!m)
|
|
2122
|
+
return [];
|
|
2123
|
+
const cur = q.total.currency;
|
|
2124
|
+
const memberTotal = money(m.publicTotal.minor - m.saved.minor, cur);
|
|
2125
|
+
const atHotel = !q.lines.some((l) => l.payment === 'prepaid');
|
|
2126
|
+
const stays = new Set(q.lines.flatMap((l) => (l.kind === 'room' ? [`${l.hotelId}|${l.stay?.checkIn}`] : []))).size;
|
|
2127
|
+
return [
|
|
2128
|
+
`${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
|
|
2129
|
+
? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
|
|
2130
|
+
: ''} → **${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' : ''}.`,
|
|
2131
|
+
q.couponRefused
|
|
2132
|
+
? `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.`
|
|
2133
|
+
: '',
|
|
2134
|
+
...(q.coupons?.length
|
|
2135
|
+
? [
|
|
2136
|
+
`The member's coupons for this trip - ${COUPON_RULE}`,
|
|
2137
|
+
...q.coupons.map((c) => `- ${couponName(c)} (\`${c.id}\`): ${q.coupon?.id === c.id
|
|
2138
|
+
? 'taken off above'
|
|
2139
|
+
: c.off
|
|
2140
|
+
? `would take ${formatMoney(c.off)} off${stays > 1 && c.hotelName ? ` (the stay at ${c.hotelName})` : ''}`
|
|
2141
|
+
: `cannot be used here: ${c.why ?? 'it does not apply.'}`}`),
|
|
2142
|
+
]
|
|
2143
|
+
: []),
|
|
2144
|
+
].filter(Boolean);
|
|
2145
|
+
};
|
|
1931
2146
|
const tripText = (quote, demo) => {
|
|
1932
2147
|
if (quote.lines.length === 0)
|
|
1933
2148
|
return 'The trip is empty.';
|
|
@@ -1936,7 +2151,8 @@ const tripText = (quote, demo) => {
|
|
|
1936
2151
|
: `- ${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)}`);
|
|
1937
2152
|
return [
|
|
1938
2153
|
...lines,
|
|
1939
|
-
`Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
|
|
2154
|
+
`Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
|
|
2155
|
+
...memberTripLines(quote),
|
|
1940
2156
|
...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
|
|
1941
2157
|
demo ? DEMO_NOTE : '',
|
|
1942
2158
|
]
|
|
@@ -2342,6 +2558,13 @@ const bookingLine = (b) => {
|
|
|
2342
2558
|
? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
|
|
2343
2559
|
: b.status}`,
|
|
2344
2560
|
kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
|
|
2561
|
+
b.quote.coupon
|
|
2562
|
+
? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
|
|
2563
|
+
? ' (given back)'
|
|
2564
|
+
: b.couponReturned === false
|
|
2565
|
+
? ' (ran out - not given back)'
|
|
2566
|
+
: ''}`
|
|
2567
|
+
: '',
|
|
2345
2568
|
b.payment
|
|
2346
2569
|
? ` · ${paymentWords(b)}${b.payment.status === 'awaiting' && b.payment.url ? ` by ${b.payment.payByLocal ?? b.payment.payBy}: ${b.payment.url}` : ''}`
|
|
2347
2570
|
: '',
|
|
@@ -2637,15 +2860,38 @@ const guarded = async (deps, ctx, who, code, run) => {
|
|
|
2637
2860
|
* read as the hotel's. Now it is two quotes, drawn side by side and never
|
|
2638
2861
|
* added up; each hotel is booked, and paid, in its own.
|
|
2639
2862
|
*/
|
|
2640
|
-
const quoteParts = async (deps, lines, who) => {
|
|
2863
|
+
const quoteParts = async (deps, lines, who, coupon) => {
|
|
2641
2864
|
const byCurrency = new Map();
|
|
2642
2865
|
for (const l of lines) {
|
|
2643
2866
|
const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
|
|
2644
2867
|
byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
|
|
2645
2868
|
}
|
|
2869
|
+
const asked = coupon ? { coupon } : {};
|
|
2646
2870
|
if (byCurrency.size === 0)
|
|
2647
|
-
return [await deps.backend.quote({ lines: [] }, who)];
|
|
2648
|
-
|
|
2871
|
+
return [await deps.backend.quote({ lines: [], ...asked }, who)];
|
|
2872
|
+
const parts = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
|
|
2873
|
+
// A coupon goes on one part of a trip in two currencies: the others need
|
|
2874
|
+
// not say it is in the wrong one.
|
|
2875
|
+
return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
|
|
2876
|
+
};
|
|
2877
|
+
/** The coupon a call names - only where the backend takes coupons (`capabilities.coupons`). */
|
|
2878
|
+
const couponOf = (deps, args) => (deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
|
|
2879
|
+
? str(args.coupon) || undefined
|
|
2880
|
+
: undefined;
|
|
2881
|
+
/**
|
|
2882
|
+
* The signed-in member as the backend has them now - their coupons spent or
|
|
2883
|
+
* handed back since they signed in. Every answer's badge reads the store, so
|
|
2884
|
+
* the card's member page is never a booking behind.
|
|
2885
|
+
*/
|
|
2886
|
+
const refreshMember = async (deps, key) => {
|
|
2887
|
+
if (!(deps.backend.capabilities ?? FULL_CAPABILITIES).coupons)
|
|
2888
|
+
return;
|
|
2889
|
+
const m = deps.members.read(key);
|
|
2890
|
+
if (!m)
|
|
2891
|
+
return;
|
|
2892
|
+
const now = await deps.backend.signInMember(m.number, m.surname);
|
|
2893
|
+
if (now)
|
|
2894
|
+
deps.members.set(key, { ...now, ...(m.preferences ? { preferences: m.preferences } : {}) });
|
|
2649
2895
|
};
|
|
2650
2896
|
/** The trip, priced, as both views need it. */
|
|
2651
2897
|
/**
|
|
@@ -2686,9 +2932,11 @@ const needsRoomIn = (t, lines, parts) => {
|
|
|
2686
2932
|
* `store`) the picked room's slot. `key` stays the conversation's trip key: it
|
|
2687
2933
|
* is the one `common` reads, so a header's trip count never counts a pick.
|
|
2688
2934
|
*/
|
|
2689
|
-
const currentTrip = async (deps, key, who, store = key) => {
|
|
2935
|
+
const currentTrip = async (deps, key, who, store = key, coupon) => {
|
|
2936
|
+
await refreshMember(deps, key);
|
|
2937
|
+
const member = deps.members.read(key);
|
|
2690
2938
|
const lines = deps.trips.read(store);
|
|
2691
|
-
const parts = await quoteParts(deps, lines, who);
|
|
2939
|
+
const parts = await quoteParts(deps, lines, who && member ? { ...who, member } : who, coupon);
|
|
2692
2940
|
// ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
|
|
2693
2941
|
// priced trip answer goes through here: add_to_trip, view_trip, and the
|
|
2694
2942
|
// card's own trip view.
|
|
@@ -3447,7 +3695,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3447
3695
|
: !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
|
|
3448
3696
|
if (held) {
|
|
3449
3697
|
deps.trips.set(store, unset(held));
|
|
3450
|
-
return currentTrip(deps, key, who, store);
|
|
3698
|
+
return currentTrip(deps, key, who, store, couponOf(deps, args));
|
|
3451
3699
|
}
|
|
3452
3700
|
}
|
|
3453
3701
|
const today = (startTime || !asStay(args)
|
|
@@ -3507,7 +3755,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3507
3755
|
hours: hourlyRoom.hourly.hours,
|
|
3508
3756
|
},
|
|
3509
3757
|
});
|
|
3510
|
-
return currentTrip(deps, key, who, store);
|
|
3758
|
+
return currentTrip(deps, key, who, store, couponOf(deps, args));
|
|
3511
3759
|
}
|
|
3512
3760
|
const stay = asStay(args);
|
|
3513
3761
|
// ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
|
|
@@ -3653,7 +3901,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3653
3901
|
stay,
|
|
3654
3902
|
rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
|
|
3655
3903
|
});
|
|
3656
|
-
const now = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
3904
|
+
const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
|
|
3657
3905
|
// Which rate was taken, in the structure too and in the same words - see
|
|
3658
3906
|
// the itinerary's `nudges` (2026-10-27 audit, F11).
|
|
3659
3907
|
return took
|
|
@@ -3740,7 +3988,14 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3740
3988
|
const { requests, dropped } = readRequests(args);
|
|
3741
3989
|
try {
|
|
3742
3990
|
const shown = deps.trips.shown(store);
|
|
3743
|
-
const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking(
|
|
3991
|
+
const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking(
|
|
3992
|
+
// The coupon the guest named, spent on the one stay it goes on - or
|
|
3993
|
+
// nothing is booked (see `TripDraft.coupon`).
|
|
3994
|
+
{
|
|
3995
|
+
lines,
|
|
3996
|
+
...(shown ? { shown } : {}),
|
|
3997
|
+
...(couponOf(deps, args) ? { coupon: couponOf(deps, args) } : {}),
|
|
3998
|
+
}, {
|
|
3744
3999
|
...guest,
|
|
3745
4000
|
...(str(args.phone) ? { phone: str(args.phone) } : {}),
|
|
3746
4001
|
...(requests?.note ? { notes: requests.note } : {}),
|
|
@@ -3754,6 +4009,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3754
4009
|
// only place an Identity is built; the guest's email still overrides,
|
|
3755
4010
|
// because a booking is found by the address it was made with.
|
|
3756
4011
|
{ ...identity(deps, ctx, key), email: guest.email }, requests);
|
|
4012
|
+
// A coupon spent is spent on the member's badge too.
|
|
4013
|
+
await refreshMember(deps, key);
|
|
3757
4014
|
// ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
|
|
3758
4015
|
// and all - to ask again, or to put another hotel in its place (robin,
|
|
3759
4016
|
// 2026-10-14).
|
|
@@ -3960,6 +4217,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3960
4217
|
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
3961
4218
|
return { ...again, text: [words, again.text].join('\n\n') };
|
|
3962
4219
|
}
|
|
4220
|
+
// The coupon asked for cannot be used: nothing was booked, and the trip
|
|
4221
|
+
// is shown again with it refused and why - the guest decides what next.
|
|
4222
|
+
if (err.code === 'coupon') {
|
|
4223
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
|
|
4224
|
+
return {
|
|
4225
|
+
...again,
|
|
4226
|
+
structured: { ...again.structured, notBooked: 'coupon' },
|
|
4227
|
+
text: [words, again.text].join('\n\n'),
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
3963
4230
|
if (err.code === 'mixed-supplier') {
|
|
3964
4231
|
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
3965
4232
|
return {
|
|
@@ -4191,6 +4458,41 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4191
4458
|
.filter((x) => !!x)
|
|
4192
4459
|
.sort();
|
|
4193
4460
|
const today = days.length ? { min: days[0], max: days.at(-1) } : undefined;
|
|
4461
|
+
// ⚠️ THE HOME OF A FEW OPENS ON TONIGHT, PRICED (robin, 2026-10-30: 「我刚刚
|
|
4462
|
+
// 打开华住,没自动选日期」 - of three ways, he chose 「默认今晚并直接出价」).
|
|
4463
|
+
// 0.50.3 left the dates empty because dates over prices that were not
|
|
4464
|
+
// theirs read as that night's prices; here the dates and the prices are
|
|
4465
|
+
// the same night, so that cannot happen. Tonight is the hotels' own date
|
|
4466
|
+
// (the earliest of them, `todayThere`), for the default party; asked again
|
|
4467
|
+
// with those nights, the answer is the home as before - priced. Only the
|
|
4468
|
+
// home of a deployment that fits on one page (the list of all, or the one
|
|
4469
|
+
// hotel's page): a group of many still chooses a city first, a market its
|
|
4470
|
+
// group, and dates or a place the model gave are used as given.
|
|
4471
|
+
const homeOfFew = !!choosing &&
|
|
4472
|
+
!asked.city &&
|
|
4473
|
+
!deps.backend.suppliers &&
|
|
4474
|
+
total >= 1 &&
|
|
4475
|
+
total <= HOME_LIST_MAX &&
|
|
4476
|
+
hotels.length === total;
|
|
4477
|
+
if (homeOfFew && !ask.stay && today?.min) {
|
|
4478
|
+
const tonight = {
|
|
4479
|
+
checkIn: today.min,
|
|
4480
|
+
checkOut: addDays(today.min, 1),
|
|
4481
|
+
guests: guestsOf(args),
|
|
4482
|
+
};
|
|
4483
|
+
const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
|
|
4484
|
+
// In both, in the same words: the model reads the structure.
|
|
4485
|
+
const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, one room - every price here is for that night. Say so: the prices shown are tonight's, and the guest can change the dates on the card or name others. Do not ask for dates before showing them.`;
|
|
4486
|
+
// Right after what is here - "every one, and no others" stays the first thing said.
|
|
4487
|
+
const inAll = String(priced.structured.inAll ?? '');
|
|
4488
|
+
return {
|
|
4489
|
+
...priced,
|
|
4490
|
+
structured: { ...priced.structured, stayDefault },
|
|
4491
|
+
text: inAll && priced.text.startsWith(inAll)
|
|
4492
|
+
? `${inAll} ${stayDefault}${priced.text.slice(inAll.length)}`
|
|
4493
|
+
: `${stayDefault}\n\n${priced.text}`,
|
|
4494
|
+
};
|
|
4495
|
+
}
|
|
4194
4496
|
// ⚠️ ONE HOTEL IS NO CHOICE OF PLACES (robin, 2026-10-15: 「现在只有杭州一家
|
|
4195
4497
|
// 了,首页也要改」). Huazhu's test system has one hotel, and "where to?" over
|
|
4196
4498
|
// a search box, one city chip and "browse all 1 hotel" was a page of ways
|
|
@@ -4620,6 +4922,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4620
4922
|
: {}),
|
|
4621
4923
|
...(r.freeCancellationUntil ? { freeCancellationLocal: freeUntilText(r) } : {}),
|
|
4622
4924
|
}));
|
|
4925
|
+
const memberRates = memberRatesOf(enough, roomsWanted);
|
|
4623
4926
|
return {
|
|
4624
4927
|
structured: {
|
|
4625
4928
|
view: 'hotel',
|
|
@@ -4632,9 +4935,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4632
4935
|
// Types with no room to book for these nights, said and not left out.
|
|
4633
4936
|
...(byTheHour || !soldOut.length ? {} : { soldOut }),
|
|
4634
4937
|
...(notSold ? { notSoldHere: notSold } : {}),
|
|
4938
|
+
...(memberRates ? { memberRates } : {}),
|
|
4635
4939
|
...flex.structured,
|
|
4636
4940
|
},
|
|
4637
4941
|
text: [
|
|
4942
|
+
memberRates?.said ?? '',
|
|
4638
4943
|
...roomsText(h.name, byTheHour
|
|
4639
4944
|
? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
|
|
4640
4945
|
: (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),
|
|
@@ -4918,7 +5223,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4918
5223
|
return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
|
|
4919
5224
|
}
|
|
4920
5225
|
if (name === t.viewTrip)
|
|
4921
|
-
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
5226
|
+
return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
|
|
4922
5227
|
if (name === t.clearTrip) {
|
|
4923
5228
|
deps.trips.clear(key);
|
|
4924
5229
|
return {
|
|
@@ -5330,10 +5635,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5330
5635
|
}
|
|
5331
5636
|
: {}),
|
|
5332
5637
|
toolToConfirm: t.cancelBooking,
|
|
5638
|
+
// Whether cancelling hands the coupon back - the same sentence as the text.
|
|
5639
|
+
...(preview.coupon
|
|
5640
|
+
? { coupon: { ...preview.coupon, said: couponBackWords(preview.coupon) } }
|
|
5641
|
+
: {}),
|
|
5333
5642
|
},
|
|
5334
5643
|
text: [
|
|
5335
5644
|
`Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
|
|
5336
5645
|
preview.rule,
|
|
5646
|
+
preview.coupon ? couponBackWords(preview.coupon) : '',
|
|
5337
5647
|
preview.cancellable
|
|
5338
5648
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
5339
5649
|
: '',
|
|
@@ -5368,6 +5678,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5368
5678
|
const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
|
|
5369
5679
|
if (!cancelled)
|
|
5370
5680
|
return asPreview;
|
|
5681
|
+
// A coupon handed back is back on the member's badge.
|
|
5682
|
+
await refreshMember(deps, key);
|
|
5371
5683
|
// ⚠️ WHAT THE HOTEL SAYS IT CHARGED, once it has said. A group's own system
|
|
5372
5684
|
// answers a cancellation with its penalty; the preview was our reading of
|
|
5373
5685
|
// the rate's terms, and where the two differ the hotel's is the one that
|
|
@@ -5398,6 +5710,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5398
5710
|
? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
5399
5711
|
`${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.`}`
|
|
5400
5712
|
: `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
|
|
5713
|
+
cancelled.quote.coupon && cancelled.couponReturned !== undefined
|
|
5714
|
+
? cancelled.couponReturned
|
|
5715
|
+
? `The coupon ${couponName(cancelled.quote.coupon)} is back with the member, usable until ${cancelled.quote.coupon.validUntil}.`
|
|
5716
|
+
: `The coupon ${couponName(cancelled.quote.coupon)} ran out on ${cancelled.quote.coupon.validUntil}, so it is not given back.`
|
|
5717
|
+
: '',
|
|
5401
5718
|
demoBookingNote(deps, cancelled.supplier),
|
|
5402
5719
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5403
5720
|
]
|
|
@@ -5615,17 +5932,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5615
5932
|
}
|
|
5616
5933
|
deps.members.set(key, member);
|
|
5617
5934
|
return {
|
|
5618
|
-
structured: {
|
|
5935
|
+
structured: {
|
|
5936
|
+
view: 'member',
|
|
5937
|
+
...common(deps, key),
|
|
5938
|
+
member: memberForCard(member, deps.vocab.brand),
|
|
5939
|
+
},
|
|
5619
5940
|
text: [
|
|
5620
|
-
`Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
|
|
5941
|
+
`Signed in as ${member.name}${member.names?.zh ? ` (${member.names.zh})` : ''} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
|
|
5621
5942
|
`Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
|
|
5943
|
+
...((deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
|
|
5944
|
+
? memberSaid(member, deps.vocab.brand)
|
|
5945
|
+
: [POINTS_DO_NOT_PAY]),
|
|
5622
5946
|
member.preferences?.length
|
|
5623
5947
|
? `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.`
|
|
5624
5948
|
: '',
|
|
5625
5949
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5626
5950
|
]
|
|
5627
5951
|
.filter(Boolean)
|
|
5628
|
-
.join('
|
|
5952
|
+
.join('\n'),
|
|
5629
5953
|
};
|
|
5630
5954
|
}
|
|
5631
5955
|
if (name === t.signOut) {
|
|
@@ -5651,7 +5975,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5651
5975
|
deps.members.set(key, member);
|
|
5652
5976
|
deps.members.setPreferences(m.number, []);
|
|
5653
5977
|
return {
|
|
5654
|
-
structured: {
|
|
5978
|
+
structured: {
|
|
5979
|
+
view: 'member',
|
|
5980
|
+
...common(deps, key),
|
|
5981
|
+
member: memberForCard(member, deps.vocab.brand),
|
|
5982
|
+
},
|
|
5655
5983
|
text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
|
|
5656
5984
|
};
|
|
5657
5985
|
}
|