@curless/hotel-storefront 0.50.5 → 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 +7 -0
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +308 -17
- 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
|
@@ -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,
|
|
@@ -1519,10 +1570,25 @@ const memberBadge = (deps, key) => {
|
|
|
1519
1570
|
programme: m.programme,
|
|
1520
1571
|
tier: { name: m.tier.name, discount: m.tier.discount },
|
|
1521
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) } : {}),
|
|
1522
1578
|
},
|
|
1523
1579
|
}
|
|
1524
1580
|
: { signedIn: false };
|
|
1525
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
|
+
});
|
|
1526
1592
|
/** A market's group by its id or its name, as a guest or a model would say it. */
|
|
1527
1593
|
/** How many stays that failed are each given alternatives - each is a search. */
|
|
1528
1594
|
const MAX_FAILED_WITH_ALTERNATIVES = 3;
|
|
@@ -1779,7 +1845,22 @@ const madeForCard = (deps, b) => {
|
|
|
1779
1845
|
const { confirmationCode: _unconfirmed, ...rest } = card;
|
|
1780
1846
|
return { ...rest, pending: true };
|
|
1781
1847
|
};
|
|
1782
|
-
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) => {
|
|
1783
1864
|
const paid = paidLine(deps, b);
|
|
1784
1865
|
if (paid)
|
|
1785
1866
|
return paid;
|
|
@@ -1875,6 +1956,35 @@ const langOf = (deps, _key) => deps.lang;
|
|
|
1875
1956
|
*/
|
|
1876
1957
|
const freeUntilText = (r) => r.freeCancellationLocal ??
|
|
1877
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
|
+
});
|
|
1878
1988
|
/** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
|
|
1879
1989
|
const quoteForCard = (quote, groupOf) => ({
|
|
1880
1990
|
lines: quote.lines.map((l) => ({
|
|
@@ -1925,11 +2035,114 @@ const quoteForCard = (quote, groupOf) => ({
|
|
|
1925
2035
|
// the quote's block is a different shape (a tier NAME, a saving, points).
|
|
1926
2036
|
// Two shapes on one key is how a card reads `member.tier.name` off a string.
|
|
1927
2037
|
...(quote.member
|
|
1928
|
-
? {
|
|
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
|
+
}
|
|
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
|
+
}
|
|
1929
2060
|
: {}),
|
|
2061
|
+
...(quote.coupons ? { coupons: quote.coupons.map(couponForCard) } : {}),
|
|
1930
2062
|
...(quote.stay ? { stay: quote.stay } : {}),
|
|
1931
2063
|
...(quote.dropped ? { dropped: quote.dropped } : {}),
|
|
1932
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
|
+
};
|
|
1933
2146
|
const tripText = (quote, demo) => {
|
|
1934
2147
|
if (quote.lines.length === 0)
|
|
1935
2148
|
return 'The trip is empty.';
|
|
@@ -1938,7 +2151,8 @@ const tripText = (quote, demo) => {
|
|
|
1938
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)}`);
|
|
1939
2152
|
return [
|
|
1940
2153
|
...lines,
|
|
1941
|
-
`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),
|
|
1942
2156
|
...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
|
|
1943
2157
|
demo ? DEMO_NOTE : '',
|
|
1944
2158
|
]
|
|
@@ -2344,6 +2558,13 @@ const bookingLine = (b) => {
|
|
|
2344
2558
|
? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
|
|
2345
2559
|
: b.status}`,
|
|
2346
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
|
+
: '',
|
|
2347
2568
|
b.payment
|
|
2348
2569
|
? ` · ${paymentWords(b)}${b.payment.status === 'awaiting' && b.payment.url ? ` by ${b.payment.payByLocal ?? b.payment.payBy}: ${b.payment.url}` : ''}`
|
|
2349
2570
|
: '',
|
|
@@ -2639,15 +2860,38 @@ const guarded = async (deps, ctx, who, code, run) => {
|
|
|
2639
2860
|
* read as the hotel's. Now it is two quotes, drawn side by side and never
|
|
2640
2861
|
* added up; each hotel is booked, and paid, in its own.
|
|
2641
2862
|
*/
|
|
2642
|
-
const quoteParts = async (deps, lines, who) => {
|
|
2863
|
+
const quoteParts = async (deps, lines, who, coupon) => {
|
|
2643
2864
|
const byCurrency = new Map();
|
|
2644
2865
|
for (const l of lines) {
|
|
2645
2866
|
const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
|
|
2646
2867
|
byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
|
|
2647
2868
|
}
|
|
2869
|
+
const asked = coupon ? { coupon } : {};
|
|
2648
2870
|
if (byCurrency.size === 0)
|
|
2649
|
-
return [await deps.backend.quote({ lines: [] }, who)];
|
|
2650
|
-
|
|
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 } : {}) });
|
|
2651
2895
|
};
|
|
2652
2896
|
/** The trip, priced, as both views need it. */
|
|
2653
2897
|
/**
|
|
@@ -2688,9 +2932,11 @@ const needsRoomIn = (t, lines, parts) => {
|
|
|
2688
2932
|
* `store`) the picked room's slot. `key` stays the conversation's trip key: it
|
|
2689
2933
|
* is the one `common` reads, so a header's trip count never counts a pick.
|
|
2690
2934
|
*/
|
|
2691
|
-
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);
|
|
2692
2938
|
const lines = deps.trips.read(store);
|
|
2693
|
-
const parts = await quoteParts(deps, lines, who);
|
|
2939
|
+
const parts = await quoteParts(deps, lines, who && member ? { ...who, member } : who, coupon);
|
|
2694
2940
|
// ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
|
|
2695
2941
|
// priced trip answer goes through here: add_to_trip, view_trip, and the
|
|
2696
2942
|
// card's own trip view.
|
|
@@ -3449,7 +3695,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3449
3695
|
: !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
|
|
3450
3696
|
if (held) {
|
|
3451
3697
|
deps.trips.set(store, unset(held));
|
|
3452
|
-
return currentTrip(deps, key, who, store);
|
|
3698
|
+
return currentTrip(deps, key, who, store, couponOf(deps, args));
|
|
3453
3699
|
}
|
|
3454
3700
|
}
|
|
3455
3701
|
const today = (startTime || !asStay(args)
|
|
@@ -3509,7 +3755,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3509
3755
|
hours: hourlyRoom.hourly.hours,
|
|
3510
3756
|
},
|
|
3511
3757
|
});
|
|
3512
|
-
return currentTrip(deps, key, who, store);
|
|
3758
|
+
return currentTrip(deps, key, who, store, couponOf(deps, args));
|
|
3513
3759
|
}
|
|
3514
3760
|
const stay = asStay(args);
|
|
3515
3761
|
// ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
|
|
@@ -3655,7 +3901,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3655
3901
|
stay,
|
|
3656
3902
|
rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
|
|
3657
3903
|
});
|
|
3658
|
-
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));
|
|
3659
3905
|
// Which rate was taken, in the structure too and in the same words - see
|
|
3660
3906
|
// the itinerary's `nudges` (2026-10-27 audit, F11).
|
|
3661
3907
|
return took
|
|
@@ -3742,7 +3988,14 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3742
3988
|
const { requests, dropped } = readRequests(args);
|
|
3743
3989
|
try {
|
|
3744
3990
|
const shown = deps.trips.shown(store);
|
|
3745
|
-
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
|
+
}, {
|
|
3746
3999
|
...guest,
|
|
3747
4000
|
...(str(args.phone) ? { phone: str(args.phone) } : {}),
|
|
3748
4001
|
...(requests?.note ? { notes: requests.note } : {}),
|
|
@@ -3756,6 +4009,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3756
4009
|
// only place an Identity is built; the guest's email still overrides,
|
|
3757
4010
|
// because a booking is found by the address it was made with.
|
|
3758
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);
|
|
3759
4014
|
// ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
|
|
3760
4015
|
// and all - to ask again, or to put another hotel in its place (robin,
|
|
3761
4016
|
// 2026-10-14).
|
|
@@ -3962,6 +4217,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
3962
4217
|
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
3963
4218
|
return { ...again, text: [words, again.text].join('\n\n') };
|
|
3964
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
|
+
}
|
|
3965
4230
|
if (err.code === 'mixed-supplier') {
|
|
3966
4231
|
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
3967
4232
|
return {
|
|
@@ -4657,6 +4922,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4657
4922
|
: {}),
|
|
4658
4923
|
...(r.freeCancellationUntil ? { freeCancellationLocal: freeUntilText(r) } : {}),
|
|
4659
4924
|
}));
|
|
4925
|
+
const memberRates = memberRatesOf(enough, roomsWanted);
|
|
4660
4926
|
return {
|
|
4661
4927
|
structured: {
|
|
4662
4928
|
view: 'hotel',
|
|
@@ -4669,9 +4935,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4669
4935
|
// Types with no room to book for these nights, said and not left out.
|
|
4670
4936
|
...(byTheHour || !soldOut.length ? {} : { soldOut }),
|
|
4671
4937
|
...(notSold ? { notSoldHere: notSold } : {}),
|
|
4938
|
+
...(memberRates ? { memberRates } : {}),
|
|
4672
4939
|
...flex.structured,
|
|
4673
4940
|
},
|
|
4674
4941
|
text: [
|
|
4942
|
+
memberRates?.said ?? '',
|
|
4675
4943
|
...roomsText(h.name, byTheHour
|
|
4676
4944
|
? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
|
|
4677
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),
|
|
@@ -4955,7 +5223,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4955
5223
|
return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
|
|
4956
5224
|
}
|
|
4957
5225
|
if (name === t.viewTrip)
|
|
4958
|
-
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
5226
|
+
return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
|
|
4959
5227
|
if (name === t.clearTrip) {
|
|
4960
5228
|
deps.trips.clear(key);
|
|
4961
5229
|
return {
|
|
@@ -5367,10 +5635,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5367
5635
|
}
|
|
5368
5636
|
: {}),
|
|
5369
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
|
+
: {}),
|
|
5370
5642
|
},
|
|
5371
5643
|
text: [
|
|
5372
5644
|
`Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
|
|
5373
5645
|
preview.rule,
|
|
5646
|
+
preview.coupon ? couponBackWords(preview.coupon) : '',
|
|
5374
5647
|
preview.cancellable
|
|
5375
5648
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
5376
5649
|
: '',
|
|
@@ -5405,6 +5678,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5405
5678
|
const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
|
|
5406
5679
|
if (!cancelled)
|
|
5407
5680
|
return asPreview;
|
|
5681
|
+
// A coupon handed back is back on the member's badge.
|
|
5682
|
+
await refreshMember(deps, key);
|
|
5408
5683
|
// ⚠️ WHAT THE HOTEL SAYS IT CHARGED, once it has said. A group's own system
|
|
5409
5684
|
// answers a cancellation with its penalty; the preview was our reading of
|
|
5410
5685
|
// the rate's terms, and where the two differ the hotel's is the one that
|
|
@@ -5435,6 +5710,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5435
5710
|
? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
5436
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.`}`
|
|
5437
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
|
+
: '',
|
|
5438
5718
|
demoBookingNote(deps, cancelled.supplier),
|
|
5439
5719
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5440
5720
|
]
|
|
@@ -5652,17 +5932,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5652
5932
|
}
|
|
5653
5933
|
deps.members.set(key, member);
|
|
5654
5934
|
return {
|
|
5655
|
-
structured: {
|
|
5935
|
+
structured: {
|
|
5936
|
+
view: 'member',
|
|
5937
|
+
...common(deps, key),
|
|
5938
|
+
member: memberForCard(member, deps.vocab.brand),
|
|
5939
|
+
},
|
|
5656
5940
|
text: [
|
|
5657
|
-
`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.`,
|
|
5658
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]),
|
|
5659
5946
|
member.preferences?.length
|
|
5660
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.`
|
|
5661
5948
|
: '',
|
|
5662
5949
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5663
5950
|
]
|
|
5664
5951
|
.filter(Boolean)
|
|
5665
|
-
.join('
|
|
5952
|
+
.join('\n'),
|
|
5666
5953
|
};
|
|
5667
5954
|
}
|
|
5668
5955
|
if (name === t.signOut) {
|
|
@@ -5688,7 +5975,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5688
5975
|
deps.members.set(key, member);
|
|
5689
5976
|
deps.members.setPreferences(m.number, []);
|
|
5690
5977
|
return {
|
|
5691
|
-
structured: {
|
|
5978
|
+
structured: {
|
|
5979
|
+
view: 'member',
|
|
5980
|
+
...common(deps, key),
|
|
5981
|
+
member: memberForCard(member, deps.vocab.brand),
|
|
5982
|
+
},
|
|
5692
5983
|
text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
|
|
5693
5984
|
};
|
|
5694
5985
|
}
|