@curless/hotel-storefront 0.18.3 → 0.19.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/__tests__/demo-hotels.test.js +21 -2
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/present.test.js +13 -0
- package/dist/__tests__/present.test.js.map +1 -1
- package/dist/__tests__/server.test.js +17 -3
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +298 -5
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +12 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +7 -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 +2 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +24 -7
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +24 -2
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +319 -95
- package/dist/demo-hotels.js.map +1 -1
- package/dist/legal.d.ts +1 -1
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +31 -10
- package/dist/legal.js.map +1 -1
- package/dist/limits.d.ts +12 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +47 -0
- package/dist/limits.js.map +1 -0
- package/dist/members.d.ts +9 -0
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +17 -0
- package/dist/members.js.map +1 -1
- package/dist/money.d.ts +6 -1
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +6 -1
- package/dist/money.js.map +1 -1
- package/dist/present.d.ts +2 -1
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +10 -6
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts +8 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -4
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts +2 -2
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +2 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +39 -13
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +391 -96
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +12 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/demo-hotels.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomInt, randomUUID } from 'node:crypto';
|
|
1
2
|
import { LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX } from './backend.js';
|
|
2
3
|
import { addDays, arrivalInstant, eachNight, isWeekendNight, nights as nightsBetween, stayProblem, todayAt, } from './dates.js';
|
|
3
4
|
import { FACILITIES, hasFacility } from './facilities.js';
|
|
@@ -31,6 +32,25 @@ const HOLD_STOPS_BEFORE_ARRIVAL_MS = 2 * 3_600_000;
|
|
|
31
32
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
32
33
|
/** Only ever used for an empty trip, which has no hotel to take a currency from. */
|
|
33
34
|
const DEFAULT_CURRENCY = 'HKD';
|
|
35
|
+
/** Conversations one booking can be opened from - its own, and a few that found it. */
|
|
36
|
+
const MAX_OWNERS = 10;
|
|
37
|
+
/**
|
|
38
|
+
* Bookings kept at once. Past it, the oldest that are over - cancelled, lapsed,
|
|
39
|
+
* or checked out - are forgotten first. A live hold is never dropped to make
|
|
40
|
+
* room: it is a room somebody was promised.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_BOOKINGS = 5_000;
|
|
43
|
+
/**
|
|
44
|
+
* Live holds one conversation may have at once. A demo's inventory is shared:
|
|
45
|
+
* one conversation asking over and over would take every room off every guest
|
|
46
|
+
* for the two hours the holds last.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_HOLDS_PER_CONVERSATION = 10;
|
|
49
|
+
const publicBooking = (b) => {
|
|
50
|
+
const { owners, ...rest } = b;
|
|
51
|
+
void owners;
|
|
52
|
+
return { ...rest };
|
|
53
|
+
};
|
|
34
54
|
/**
|
|
35
55
|
* A trip cut into the stays a hotel would confirm one by one: the rooms at one
|
|
36
56
|
* hotel for the same nights, and the add-ons bought for those nights.
|
|
@@ -71,12 +91,14 @@ export const splitByStay = (lines) => {
|
|
|
71
91
|
export const createDemoHotels = (opts) => {
|
|
72
92
|
const hotels = opts.data.hotels;
|
|
73
93
|
const now = opts.now ?? (() => Date.now());
|
|
74
|
-
|
|
94
|
+
// ⚠️ A CODE IS HALF OF WHAT OPENS A BOOKING, so it comes from the crypto
|
|
95
|
+
// generator: Math.random is predictable from its own output. Tests inject
|
|
96
|
+
// their own.
|
|
97
|
+
const rng = opts.rng ?? (() => randomInt(0, 2 ** 32) / 2 ** 32);
|
|
75
98
|
const holdMs = opts.holdMs ?? HOLD_MS_DEFAULT;
|
|
76
99
|
const bookings = new Map();
|
|
77
100
|
/** Saved room preferences, by membership number - see `preferencesFor`. */
|
|
78
101
|
const preferences = new Map();
|
|
79
|
-
let seq = 0;
|
|
80
102
|
const byId = (id) => {
|
|
81
103
|
const wanted = id.trim().toLowerCase();
|
|
82
104
|
return (hotels.find((h) => h.id.toLowerCase() === wanted) ??
|
|
@@ -185,6 +207,22 @@ export const createDemoHotels = (opts) => {
|
|
|
185
207
|
b.status = 'expired';
|
|
186
208
|
}
|
|
187
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* Until when this room on these nights can be cancelled for nothing - or
|
|
212
|
+
* nothing, when it cannot.
|
|
213
|
+
*
|
|
214
|
+
* ⚠️ A WINDOW THAT HAS ALREADY CLOSED IS NO WINDOW. A stay two days out on a
|
|
215
|
+
* 48-hour rule was listed as "Free cancellation", compared as "Yes" and
|
|
216
|
+
* described as "free to cancel until" yesterday - and booked, it cost a
|
|
217
|
+
* night to cancel (2026-09-28 audit).
|
|
218
|
+
*/
|
|
219
|
+
const freeUntilOf = (h, r, stay) => {
|
|
220
|
+
if (!r.refundable || r.freeCancelHours === undefined)
|
|
221
|
+
return undefined;
|
|
222
|
+
const at = arrivalInstant(stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h)) -
|
|
223
|
+
r.freeCancelHours * 3_600_000;
|
|
224
|
+
return at > now() ? new Date(at).toISOString() : undefined;
|
|
225
|
+
};
|
|
188
226
|
const priceRoom = (h, r, stay, who,
|
|
189
227
|
/** A booking whose own rooms do not count against it — see `heldOn`. */
|
|
190
228
|
ignore) => {
|
|
@@ -199,10 +237,7 @@ export const createDemoHotels = (opts) => {
|
|
|
199
237
|
: publicSubtotal;
|
|
200
238
|
const taxes = wholeUnits(taxOn(roomSubtotal, h.taxRate));
|
|
201
239
|
const publicTaxes = wholeUnits(taxOn(publicSubtotal, h.taxRate));
|
|
202
|
-
const freeUntil =
|
|
203
|
-
? new Date(arrivalInstant(stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h)) -
|
|
204
|
-
r.freeCancelHours * 3_600_000).toISOString()
|
|
205
|
-
: undefined;
|
|
240
|
+
const freeUntil = freeUntilOf(h, r, stay);
|
|
206
241
|
return {
|
|
207
242
|
id: r.id,
|
|
208
243
|
name: r.name,
|
|
@@ -312,13 +347,50 @@ export const createDemoHotels = (opts) => {
|
|
|
312
347
|
if (!inUse(`SL-${out}${n}`))
|
|
313
348
|
return `SL-${out}${n}`;
|
|
314
349
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
350
|
+
/**
|
|
351
|
+
* One booking by its id or the code a guest quotes, and only if it is theirs:
|
|
352
|
+
* this conversation's, or found with its code and the email it was made with.
|
|
353
|
+
*
|
|
354
|
+
* ⚠️ WITH AN EMAIL, THE CODE AND ONLY THE CODE. The id was accepted too, and
|
|
355
|
+
* it was `bk_<the time>_<a counter>` - an email and a few dozen guesses
|
|
356
|
+
* opened somebody's booking (2026-09-28 audit). Ids are random now, and
|
|
357
|
+
* still not taken with an email: the code is what the guest was given.
|
|
358
|
+
*
|
|
359
|
+
* Found that way, the booking is this conversation's from then on - see
|
|
360
|
+
* `StoredBooking`.
|
|
361
|
+
*/
|
|
318
362
|
const findBooking = (ref, who) => {
|
|
319
363
|
const wanted = ref.trim().toLowerCase();
|
|
320
|
-
|
|
321
|
-
|
|
364
|
+
if (!wanted)
|
|
365
|
+
return undefined;
|
|
366
|
+
const own = [...bookings.values()].find((x) => x.owners.has(who.sessionId) &&
|
|
367
|
+
(x.id.toLowerCase() === wanted || x.confirmationCode.toLowerCase() === wanted));
|
|
368
|
+
if (own || !who.email)
|
|
369
|
+
return own;
|
|
370
|
+
const email = who.email.trim().toLowerCase();
|
|
371
|
+
const there = [...bookings.values()].find((x) => x.confirmationCode.toLowerCase() === wanted && x.guest.email.trim().toLowerCase() === email);
|
|
372
|
+
if (there) {
|
|
373
|
+
there.owners.add(who.sessionId);
|
|
374
|
+
while (there.owners.size > MAX_OWNERS) {
|
|
375
|
+
const [, second] = [...there.owners];
|
|
376
|
+
// The conversation that made it stays; the oldest finder goes.
|
|
377
|
+
if (second === undefined)
|
|
378
|
+
break;
|
|
379
|
+
there.owners.delete(second);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return there;
|
|
383
|
+
};
|
|
384
|
+
/** Over and done with - nothing held, nothing left to change. */
|
|
385
|
+
const ended = (b) => (b.status !== 'requested' && b.status !== 'confirmed') || b.stay.checkOut < today(b.hotelId);
|
|
386
|
+
/** Room for one more, by forgetting the oldest booking that is over. */
|
|
387
|
+
const makeRoom = (incoming) => {
|
|
388
|
+
for (const b of [...bookings.values()]) {
|
|
389
|
+
if (bookings.size + incoming <= MAX_BOOKINGS)
|
|
390
|
+
return;
|
|
391
|
+
if (ended(b))
|
|
392
|
+
bookings.delete(b.id);
|
|
393
|
+
}
|
|
322
394
|
};
|
|
323
395
|
/** A room type by its id or the name the hotel prints. */
|
|
324
396
|
const roomOf = (h, ref) => {
|
|
@@ -441,7 +513,8 @@ export const createDemoHotels = (opts) => {
|
|
|
441
513
|
const units = extraUnits(rawExtra, stay, line.quantity);
|
|
442
514
|
const total = multiplyMoney(extra.price, units);
|
|
443
515
|
subtotal += total.minor;
|
|
444
|
-
|
|
516
|
+
// Each add-on's tax rounded on its own, like each room's - see `taxOn`.
|
|
517
|
+
taxes += wholeUnits(taxOn(total, h.taxRate)).minor;
|
|
445
518
|
lines.push({
|
|
446
519
|
kind: 'extra',
|
|
447
520
|
hotelId: h.id,
|
|
@@ -473,9 +546,8 @@ export const createDemoHotels = (opts) => {
|
|
|
473
546
|
}
|
|
474
547
|
: undefined;
|
|
475
548
|
const member = who?.member;
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
const taxed = wholeUnits(money(taxes, cur)).minor;
|
|
549
|
+
// Every line's tax is already a whole unit - see `taxOn`.
|
|
550
|
+
const taxed = taxes;
|
|
479
551
|
return {
|
|
480
552
|
lines,
|
|
481
553
|
subtotal: money(subtotal, cur),
|
|
@@ -528,6 +600,55 @@ export const createDemoHotels = (opts) => {
|
|
|
528
600
|
};
|
|
529
601
|
});
|
|
530
602
|
};
|
|
603
|
+
/**
|
|
604
|
+
* The tax on one stored room line, exactly as it was charged: `priceRoom`
|
|
605
|
+
* rounds each room's tax to the whole unit, and a line is that room times
|
|
606
|
+
* how many.
|
|
607
|
+
*/
|
|
608
|
+
const roomTaxOf = (l, taxRate) => {
|
|
609
|
+
const q = Math.max(1, l.quantity ?? 1);
|
|
610
|
+
return wholeUnits(taxOn(money(l.total.minor / q, l.total.currency), taxRate)).minor * q;
|
|
611
|
+
};
|
|
612
|
+
/**
|
|
613
|
+
* The booking with its add-ons re-priced and its ROOMS AS BOOKED.
|
|
614
|
+
*
|
|
615
|
+
* ⚠️ AN AIRPORT TRANSFER DOES NOT RE-PRICE THE ROOM. Every line used to be
|
|
616
|
+
* re-quoted for whoever was asking: a member's non-refundable room, with the
|
|
617
|
+
* member signed out, came back at the public rate, and "HK$2,879 more" was
|
|
618
|
+
* shown for a HK$1,413 transfer - the room's own non-refundable fee rising
|
|
619
|
+
* with it (2026-09-28 audit). A rate is what the booking was made on; only
|
|
620
|
+
* moving the nights or the room changes it.
|
|
621
|
+
*/
|
|
622
|
+
const withRoomsKept = (was, fresh, taxRate) => {
|
|
623
|
+
const cur = was.total.currency;
|
|
624
|
+
const rooms = was.lines.filter((l) => l.kind === 'room');
|
|
625
|
+
const extras = fresh.lines.filter((l) => l.kind === 'extra');
|
|
626
|
+
const lines = [...rooms, ...extras];
|
|
627
|
+
const subtotal = lines.reduce((n, l) => n + l.total.minor, 0);
|
|
628
|
+
const taxes = rooms.reduce((n, l) => n + roomTaxOf(l, taxRate), 0) +
|
|
629
|
+
extras.reduce((n, l) => n + wholeUnits(taxOn(l.total, taxRate)).minor, 0);
|
|
630
|
+
const total = subtotal + taxes;
|
|
631
|
+
const { dropped, member, ...rest } = was;
|
|
632
|
+
void dropped;
|
|
633
|
+
return {
|
|
634
|
+
...rest,
|
|
635
|
+
lines,
|
|
636
|
+
subtotal: money(subtotal, cur),
|
|
637
|
+
taxesAndFees: money(taxes, cur),
|
|
638
|
+
total: money(total, cur),
|
|
639
|
+
// Points are earned on what is paid, and scale with it.
|
|
640
|
+
...(member
|
|
641
|
+
? {
|
|
642
|
+
member: {
|
|
643
|
+
...member,
|
|
644
|
+
points: was.total.minor
|
|
645
|
+
? Math.round((member.points * total) / was.total.minor)
|
|
646
|
+
: member.points,
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
: {}),
|
|
650
|
+
};
|
|
651
|
+
};
|
|
531
652
|
/**
|
|
532
653
|
* A change, worked out whole: what the preview shows AND the quote the change
|
|
533
654
|
* would store, from one computation.
|
|
@@ -579,6 +700,9 @@ export const createDemoHotels = (opts) => {
|
|
|
579
700
|
change.checkOut !== undefined ||
|
|
580
701
|
change.room !== undefined ||
|
|
581
702
|
change.rooms !== undefined;
|
|
703
|
+
// An add-on asked for at the count it already has is no part of the change.
|
|
704
|
+
if (addOn && addOn.from === addOn.to && movesRooms)
|
|
705
|
+
addOn = undefined;
|
|
582
706
|
// ⚠️ A NON-REFUNDABLE RATE CANNOT BE MOVED EITHER - ANY ROOM ON IT. The
|
|
583
707
|
// hotel's own words are "cannot be cancelled or changed"; offering a change
|
|
584
708
|
// would be this card contradicting the policy on the screen beside it.
|
|
@@ -589,6 +713,9 @@ export const createDemoHotels = (opts) => {
|
|
|
589
713
|
: undefined;
|
|
590
714
|
if (locked)
|
|
591
715
|
return no(`${locked.title} is a non-refundable rate: it cannot be cancelled or changed. Book the new nights as their own stay - this one stands.`);
|
|
716
|
+
// "No rooms" is not a smaller booking. It became one room without a word.
|
|
717
|
+
if (change.rooms !== undefined && !(Math.floor(change.rooms) >= 1))
|
|
718
|
+
return no('A booking keeps at least one room. To give them all back, cancel it instead.');
|
|
592
719
|
if (rooms.length > 1 && (change.room !== undefined || change.rooms !== undefined))
|
|
593
720
|
return no(`This booking holds ${rooms.length} room types (${from.roomName}). A change of room type or of how many works on a booking of one; the dates can still move, or cancel it and book again.`);
|
|
594
721
|
const raw = h.rooms.find((r) => r.id === line.roomRef);
|
|
@@ -625,7 +752,7 @@ export const createDemoHotels = (opts) => {
|
|
|
625
752
|
// on the nights that do not overlap.
|
|
626
753
|
const one = rooms.length === 1;
|
|
627
754
|
const left = priceRoom(h, wantedRoom, stay, who, b.id).roomsLeft;
|
|
628
|
-
if (one && left < count)
|
|
755
|
+
if (movesRooms && one && left < count)
|
|
629
756
|
return no(`${wantedRoom.name} has ${left} left for ${stay.checkIn} to ${stay.checkOut}; this change needs ${count}.`);
|
|
630
757
|
const moved = linesOn(b, stay, one ? { roomId: wantedRoom.id, rooms: count } : undefined);
|
|
631
758
|
// The add-on set to its new count - taken off at 0, put on when it was not.
|
|
@@ -637,12 +764,25 @@ export const createDemoHotels = (opts) => {
|
|
|
637
764
|
: []),
|
|
638
765
|
]
|
|
639
766
|
: moved;
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
767
|
+
const fresh = await quoteTrip({ lines }, who, b.id);
|
|
768
|
+
let quote = fresh;
|
|
769
|
+
if (movesRooms) {
|
|
770
|
+
const lost = fresh.dropped?.[0];
|
|
771
|
+
if (lost || !fresh.lines.some((l) => l.kind === 'room'))
|
|
772
|
+
return no(lost
|
|
773
|
+
? `${lost.title}: ${lost.why}`
|
|
774
|
+
: 'The rooms on this booking could not be held for the new nights.');
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
// Only the add-on has to price; the rooms stand as booked.
|
|
778
|
+
if (addOn &&
|
|
779
|
+
addOn.to > 0 &&
|
|
780
|
+
!fresh.lines.some((l) => l.kind === 'extra' && l.extraRef === addOn?.id)) {
|
|
781
|
+
const lost = fresh.dropped?.find((d) => d.title.includes(addOn?.id ?? '')) ?? fresh.dropped?.[0];
|
|
782
|
+
return no(lost ? `${lost.title}: ${lost.why}` : `${addOn.name} cannot be added to this booking.`);
|
|
783
|
+
}
|
|
784
|
+
quote = withRoomsKept(b.quote, fresh, h.taxRate);
|
|
785
|
+
}
|
|
646
786
|
const becomesLocked = wantedRoom.id !== line.roomRef && !wantedRoom.refundable;
|
|
647
787
|
return {
|
|
648
788
|
preview: {
|
|
@@ -685,8 +825,11 @@ export const createDemoHotels = (opts) => {
|
|
|
685
825
|
continue;
|
|
686
826
|
const h = byId(line.hotelId);
|
|
687
827
|
const taxRate = h?.taxRate ?? 0;
|
|
828
|
+
// ⚠️ THE TAX AS IT WAS CHARGED, whole units and all: a fee worked out
|
|
829
|
+
// afresh came to HK$13,200.66 on a HK$13,201 booking, and "HK$0.34 back"
|
|
830
|
+
// on a rate that returns nothing (2026-09-28 audit).
|
|
688
831
|
if (!line.refundable) {
|
|
689
|
-
const withTax =
|
|
832
|
+
const withTax = line.total.minor + roomTaxOf(line, taxRate);
|
|
690
833
|
fee += withTax;
|
|
691
834
|
rules.push(`${line.title} is a non-refundable rate: the whole stay, ${formatMoney(money(withTax, currency))}, stands.`);
|
|
692
835
|
continue;
|
|
@@ -697,9 +840,13 @@ export const createDemoHotels = (opts) => {
|
|
|
697
840
|
rules.push(`${line.title} is free to cancel until ${line.freeCancellationUntil.slice(0, 16).replace('T', ' ')} UTC.`);
|
|
698
841
|
continue;
|
|
699
842
|
}
|
|
700
|
-
|
|
843
|
+
// A night at the booking's own average nightly rate - a stay of a
|
|
844
|
+
// Thursday and a Friday is not charged as its dearer night - and said
|
|
845
|
+
// as that, so nobody reads it as a particular night's price.
|
|
846
|
+
const q = Math.max(1, line.quantity ?? 1);
|
|
847
|
+
const oneNight = (line.unit.minor + wholeUnits(taxOn(line.unit, taxRate)).minor) * q;
|
|
701
848
|
fee += oneNight;
|
|
702
|
-
rules.push(`${line.title}: the free window closed${line.freeCancellationUntil ? ` on ${line.freeCancellationUntil.slice(0, 10)}` : ''}, so one night plus tax, ${formatMoney(money(oneNight, currency))}, is charged.`);
|
|
849
|
+
rules.push(`${line.title}: the free window closed${line.freeCancellationUntil ? ` on ${line.freeCancellationUntil.slice(0, 10)}` : ''}, so one night at this booking's average nightly rate, plus tax, ${formatMoney(money(oneNight, currency))}, is charged.`);
|
|
703
850
|
}
|
|
704
851
|
if (b.quote.lines.some((l) => l.kind === 'extra')) {
|
|
705
852
|
rules.push('Add-ons are dropped at no charge.');
|
|
@@ -711,6 +858,9 @@ export const createDemoHotels = (opts) => {
|
|
|
711
858
|
today,
|
|
712
859
|
bookingsForTest: () => [...bookings.values()],
|
|
713
860
|
async searchHotels(q, who) {
|
|
861
|
+
// A lapsed hold stops holding here too: without it a list priced for the
|
|
862
|
+
// nights still said "No room" until some other call swept.
|
|
863
|
+
sweep();
|
|
714
864
|
const terms = (q.query ?? '')
|
|
715
865
|
.toLowerCase()
|
|
716
866
|
.split(/\s+/)
|
|
@@ -747,12 +897,87 @@ export const createDemoHotels = (opts) => {
|
|
|
747
897
|
// A room term is about a ROOM: a hotel passes when it has one, and on a
|
|
748
898
|
// dated search that is the room that gets priced.
|
|
749
899
|
const f = q.filters ?? {};
|
|
750
|
-
const
|
|
900
|
+
const stayAsked = q.stay;
|
|
901
|
+
const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
|
|
902
|
+
// A room term is about a ROOM, and on named nights about THOSE nights:
|
|
903
|
+
// free cancellation is a window that has not closed yet, not a rate that
|
|
904
|
+
// once had one.
|
|
905
|
+
const roomOk = (h, r, over) => (!over.freeCancellation || (stayAsked ? !!freeUntilOf(h, r, stayAsked) : r.refundable)) &&
|
|
906
|
+
(!over.breakfast || !!r.breakfast);
|
|
751
907
|
const passes = (h, over) => (!over.brand || h.brand.toLowerCase() === over.brand.toLowerCase()) &&
|
|
752
908
|
(over.facilities ?? []).every((k) => hasFacility(h.amenities, k)) &&
|
|
753
|
-
(!(over.freeCancellation || over.breakfast) || h.rooms.some((r) => roomOk(r, over)));
|
|
909
|
+
(!(over.freeCancellation || over.breakfast) || h.rooms.some((r) => roomOk(h, r, over)));
|
|
754
910
|
const matches = placed.filter((h) => passes(h, f));
|
|
755
|
-
|
|
911
|
+
// ⚠️ NAMED NIGHTS ARE PRICED, EVERY HOTEL (robin, 2026-09-28, with Trip.com
|
|
912
|
+
// and the group's own site as the reference: a place, then the dates,
|
|
913
|
+
// then hotels priced for THOSE dates). The cheapest room that takes the
|
|
914
|
+
// party, meets the room terms and has as many rooms left as were asked
|
|
915
|
+
// for, for the whole stay, taxes in - and the price order and the
|
|
916
|
+
// ceiling are about that room, not the list rate a Friday never costs.
|
|
917
|
+
//
|
|
918
|
+
// Per room terms, because a chip's count is asked with other terms than
|
|
919
|
+
// the list's own, and the cheapest room with breakfast is a different
|
|
920
|
+
// room at a different price.
|
|
921
|
+
const pricedRooms = new Map();
|
|
922
|
+
const best = new Map();
|
|
923
|
+
const bestFor = (h, over) => {
|
|
924
|
+
if (!stayAsked)
|
|
925
|
+
return null;
|
|
926
|
+
const k = `${h.id}|${!!over.freeCancellation}|${!!over.breakfast}`;
|
|
927
|
+
const known = best.get(k);
|
|
928
|
+
if (known !== undefined)
|
|
929
|
+
return known;
|
|
930
|
+
let found = null;
|
|
931
|
+
if (!stayProblem(stayAsked, today(h.id))) {
|
|
932
|
+
for (const raw of h.rooms) {
|
|
933
|
+
if (raw.sleeps < stayAsked.guests || !roomOk(h, raw, over))
|
|
934
|
+
continue;
|
|
935
|
+
const rk = `${h.id}|${raw.id}`;
|
|
936
|
+
const room = pricedRooms.get(rk) ?? priceRoom(h, raw, stayAsked, who);
|
|
937
|
+
pricedRooms.set(rk, room);
|
|
938
|
+
if (room.roomsLeft < wantRooms)
|
|
939
|
+
continue;
|
|
940
|
+
if (!found || room.stayTotal.minor < found.room.stayTotal.minor)
|
|
941
|
+
found = { room, raw };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
best.set(k, found);
|
|
945
|
+
return found;
|
|
946
|
+
};
|
|
947
|
+
/**
|
|
948
|
+
* The number a ceiling is held against: a night of the cheapest room
|
|
949
|
+
* that fits on named nights, the list rate otherwise - and nothing at all
|
|
950
|
+
* for a hotel with no room for those nights, which is neither under a
|
|
951
|
+
* ceiling nor over it.
|
|
952
|
+
*/
|
|
953
|
+
const nightlyOf = (h, over) => {
|
|
954
|
+
if (stayAsked) {
|
|
955
|
+
const b = bestFor(h, over);
|
|
956
|
+
return b ? b.room.nightly.minor / minorFactor(h.currency) : undefined;
|
|
957
|
+
}
|
|
958
|
+
return h.rooms.reduce((min, r) => (r.nightly < min ? r.nightly : min), Number.POSITIVE_INFINITY);
|
|
959
|
+
};
|
|
960
|
+
// ⚠️ THE CURRENCIES OF THE MATCHES, before any ceiling is applied. This
|
|
961
|
+
// is the set a price question would have to be answered across, and it is
|
|
962
|
+
// the whole reason the price knobs live behind this seam rather than in
|
|
963
|
+
// the tool: only here is it known that "Shangri-La in Asia" spans eleven
|
|
964
|
+
// currencies and "Shangri-La in Tokyo" spans one.
|
|
965
|
+
const currencies = [...new Set(matches.map((h) => h.currency))].sort();
|
|
966
|
+
const onePrice = currencies.length === 1;
|
|
967
|
+
const ceiling = onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
|
|
968
|
+
const under = (h, over) => {
|
|
969
|
+
if (ceiling === undefined)
|
|
970
|
+
return true;
|
|
971
|
+
const n = nightlyOf(h, over);
|
|
972
|
+
return n !== undefined && n <= ceiling;
|
|
973
|
+
};
|
|
974
|
+
// ⚠️ THE FILTERS, and what each would leave with the others applied - the
|
|
975
|
+
// count a chip shows before it is pressed (robin, 2026-09-28: 「筛选可以,
|
|
976
|
+
// 加上」). A brand is one at a time; facilities and room terms all apply.
|
|
977
|
+
// A chip counts what pressing it would LIST: under the ceiling too, and
|
|
978
|
+
// priced with its own room terms. It counted before the ceiling, and
|
|
979
|
+
// "Shangri-La 2" pressed gave one (2026-09-28 audit).
|
|
980
|
+
const count = (over) => placed.filter((h) => passes(h, over) && under(h, over)).length;
|
|
756
981
|
const refine = {
|
|
757
982
|
brands: [...new Set(placed.map((h) => h.brand))]
|
|
758
983
|
.map((name) => ({ name, count: count({ ...f, brand: name }) }))
|
|
@@ -802,72 +1027,65 @@ export const createDemoHotels = (opts) => {
|
|
|
802
1027
|
return size(b) - size(a) || a.cityId.localeCompare(b.cityId);
|
|
803
1028
|
})
|
|
804
1029
|
: matches;
|
|
805
|
-
// ⚠️ THE CURRENCIES OF THE MATCHES, before any ceiling is applied. This
|
|
806
|
-
// is the set a price question would have to be answered across, and it is
|
|
807
|
-
// the whole reason the price knobs live behind this seam rather than in
|
|
808
|
-
// the tool: only here is it known that "Shangri-La in Asia" spans eleven
|
|
809
|
-
// currencies and "Shangri-La in Tokyo" spans one.
|
|
810
|
-
const currencies = [...new Set(matches.map((h) => h.currency))].sort();
|
|
811
|
-
const onePrice = currencies.length === 1;
|
|
812
|
-
// ⚠️ NAMED NIGHTS ARE PRICED, EVERY HOTEL (robin, 2026-09-28, with Trip.com
|
|
813
|
-
// and the group's own site as the reference: a place, then the dates,
|
|
814
|
-
// then hotels priced for THOSE dates). The cheapest room that takes the
|
|
815
|
-
// party and has as many rooms left as were asked for, for the whole
|
|
816
|
-
// stay, taxes in - and the price order and the ceiling are about that
|
|
817
|
-
// number, not the list rate a Friday never costs.
|
|
818
|
-
const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
|
|
819
1030
|
const forStay = new Map();
|
|
820
1031
|
const noRooms = new Map();
|
|
821
|
-
if (
|
|
822
|
-
const stayAsked = q.stay;
|
|
1032
|
+
if (stayAsked) {
|
|
823
1033
|
for (const h of matches) {
|
|
824
1034
|
const problem = stayProblem(stayAsked, today(h.id));
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
continue;
|
|
835
|
-
const room = priceRoom(h, raw, stayAsked, who);
|
|
836
|
-
if (room.roomsLeft < wantRooms)
|
|
837
|
-
continue;
|
|
838
|
-
if (!best || room.stayTotal.minor < best.room.stayTotal.minor)
|
|
839
|
-
best = { room, raw };
|
|
840
|
-
}
|
|
841
|
-
if (!best) {
|
|
842
|
-
noRooms.set(h.id, 'No room here can be booked for those nights.');
|
|
1035
|
+
const b = problem ? null : bestFor(h, f);
|
|
1036
|
+
if (!b) {
|
|
1037
|
+
// Why, when it is the party: a family of three was told the hotel
|
|
1038
|
+
// had no room "for those nights", and moved the dates.
|
|
1039
|
+
const largest = Math.max(...h.rooms.map((r) => r.sleeps));
|
|
1040
|
+
noRooms.set(h.id, problem ??
|
|
1041
|
+
(largest < stayAsked.guests
|
|
1042
|
+
? `No room here sleeps ${stayAsked.guests}; the largest sleeps ${largest}, so two rooms would work.`
|
|
1043
|
+
: 'No room here can be booked for those nights.'));
|
|
843
1044
|
continue;
|
|
844
1045
|
}
|
|
845
1046
|
forStay.set(h.id, {
|
|
846
|
-
total: multiplyMoney(
|
|
847
|
-
nightly:
|
|
848
|
-
room:
|
|
1047
|
+
total: multiplyMoney(b.room.stayTotal, wantRooms),
|
|
1048
|
+
nightly: b.room.nightly,
|
|
1049
|
+
room: b.raw.name,
|
|
849
1050
|
rooms: wantRooms,
|
|
850
|
-
nights:
|
|
851
|
-
roomsLeft:
|
|
852
|
-
freeCancellation: !!
|
|
1051
|
+
nights: b.room.nights,
|
|
1052
|
+
roomsLeft: b.room.roomsLeft,
|
|
1053
|
+
freeCancellation: !!b.room.freeCancellationUntil,
|
|
853
1054
|
});
|
|
854
1055
|
}
|
|
855
1056
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
const
|
|
864
|
-
const passed =
|
|
865
|
-
|
|
1057
|
+
/**
|
|
1058
|
+
* What the price order reads: the stay's total, taxes in, when nights are
|
|
1059
|
+
* named - the number on the tile. It read the pre-tax average night, and
|
|
1060
|
+
* Pudong's ¥3,364 sorted above Kerry Pudong's ¥3,138: two taxes, one
|
|
1061
|
+
* currency (2026-09-28 audit). A hotel with no room has no price, and
|
|
1062
|
+
* goes last whichever way the list is sorted.
|
|
1063
|
+
*/
|
|
1064
|
+
const priceKey = (h) => stayAsked ? forStay.get(h.id)?.total.minor : nightlyOf(h, f);
|
|
1065
|
+
const passed = ranked.filter((h) => under(h, f));
|
|
1066
|
+
// Over the ceiling means PRICED and over it. A hotel with no room for the
|
|
1067
|
+
// nights was counted here too - "2 more came in over the ceiling" about
|
|
1068
|
+
// two that were full.
|
|
1069
|
+
const over = ceiling === undefined
|
|
1070
|
+
? []
|
|
1071
|
+
: ranked.filter((h) => {
|
|
1072
|
+
const n = nightlyOf(h, f);
|
|
1073
|
+
return n !== undefined && n > ceiling;
|
|
1074
|
+
});
|
|
866
1075
|
// A price order across currencies is an exchange rate we picked. Dropped,
|
|
867
1076
|
// not approximated; `currencies` tells the caller to say so.
|
|
868
1077
|
const sort = q.sort ?? 'group';
|
|
1078
|
+
const byPrice = (a, b, dir) => {
|
|
1079
|
+
const x = priceKey(a);
|
|
1080
|
+
const y = priceKey(b);
|
|
1081
|
+
if (x === undefined)
|
|
1082
|
+
return y === undefined ? 0 : 1;
|
|
1083
|
+
if (y === undefined)
|
|
1084
|
+
return -1;
|
|
1085
|
+
return (x - y) * dir;
|
|
1086
|
+
};
|
|
869
1087
|
const ordered = (sort === 'price-low' || sort === 'price-high') && onePrice
|
|
870
|
-
? [...passed].sort((a, b) => sort === 'price-low' ?
|
|
1088
|
+
? [...passed].sort((a, b) => byPrice(a, b, sort === 'price-low' ? 1 : -1))
|
|
871
1089
|
: sort === 'rating'
|
|
872
1090
|
? // A hotel that publishes no rating is not a hotel with a bad one.
|
|
873
1091
|
// They keep their order and follow the ones that do.
|
|
@@ -896,6 +1114,7 @@ export const createDemoHotels = (opts) => {
|
|
|
896
1114
|
currencies,
|
|
897
1115
|
refine,
|
|
898
1116
|
...(q.filters ? { beforeFilters: placed.length } : {}),
|
|
1117
|
+
...(ceiling !== undefined ? { beforeCeiling: matches.length } : {}),
|
|
899
1118
|
facets: {
|
|
900
1119
|
countries: tally((h) => h.country ?? h.city),
|
|
901
1120
|
cities: tally((h) => h.city).map((c) => ({
|
|
@@ -908,10 +1127,11 @@ export const createDemoHotels = (opts) => {
|
|
|
908
1127
|
// and never mixed in with the passes.
|
|
909
1128
|
...(passed.length === 0 && over.length
|
|
910
1129
|
? {
|
|
1130
|
+
// Priced like the list: on named nights, what those nights cost.
|
|
911
1131
|
nearest: [...over]
|
|
912
|
-
.sort((a, b) =>
|
|
1132
|
+
.sort((a, b) => byPrice(a, b, 1))
|
|
913
1133
|
.slice(0, 3)
|
|
914
|
-
.map(
|
|
1134
|
+
.map(priced),
|
|
915
1135
|
}
|
|
916
1136
|
: {}),
|
|
917
1137
|
};
|
|
@@ -1051,6 +1271,10 @@ export const createDemoHotels = (opts) => {
|
|
|
1051
1271
|
if (problems.length) {
|
|
1052
1272
|
throw demoError('partly-unavailable', `Nothing was booked. ${problems.join(' ')} The trip is as it was - take that out or change it, then ask again.`);
|
|
1053
1273
|
}
|
|
1274
|
+
const live = [...bookings.values()].filter((b) => b.owners.has(who.sessionId) && b.status === 'requested').length;
|
|
1275
|
+
if (live + groups.length > MAX_HOLDS_PER_CONVERSATION) {
|
|
1276
|
+
throw demoError('too-many-holds', `Nothing was booked. This conversation already holds ${live} booking${live === 1 ? '' : 's'}, and ${MAX_HOLDS_PER_CONVERSATION} at once is the most - cancel one that is not needed, then ask again.`);
|
|
1277
|
+
}
|
|
1054
1278
|
const quotes = await Promise.all(groups.map((g) => quoteTrip({ lines: g }, who)));
|
|
1055
1279
|
const t = now();
|
|
1056
1280
|
const made = [];
|
|
@@ -1064,9 +1288,8 @@ export const createDemoHotels = (opts) => {
|
|
|
1064
1288
|
}
|
|
1065
1289
|
const arrival = arrivalInstant(quote.stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h));
|
|
1066
1290
|
const holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
|
|
1067
|
-
seq += 1;
|
|
1068
1291
|
made.push({
|
|
1069
|
-
id: `bk_${
|
|
1292
|
+
id: `bk_${randomUUID()}`,
|
|
1070
1293
|
confirmationCode: confirmationCode(new Set(made.map((m) => m.confirmationCode))),
|
|
1071
1294
|
status: 'requested',
|
|
1072
1295
|
createdAt: new Date(t).toISOString(),
|
|
@@ -1076,26 +1299,29 @@ export const createDemoHotels = (opts) => {
|
|
|
1076
1299
|
stay: quote.stay,
|
|
1077
1300
|
guest,
|
|
1078
1301
|
quote,
|
|
1079
|
-
|
|
1302
|
+
owners: new Set([who.sessionId]),
|
|
1080
1303
|
...(cleanRequests(requests) ? { requests: cleanRequests(requests) } : {}),
|
|
1081
1304
|
});
|
|
1082
1305
|
}
|
|
1306
|
+
makeRoom(made.length);
|
|
1083
1307
|
for (const booking of made)
|
|
1084
1308
|
bookings.set(booking.id, booking);
|
|
1085
|
-
return made.map(
|
|
1309
|
+
return made.map(publicBooking);
|
|
1086
1310
|
},
|
|
1087
1311
|
async listBookings(who, limit = 10) {
|
|
1088
1312
|
sweep();
|
|
1313
|
+
// A negative limit reached `slice(0, -2)` and returned all but two.
|
|
1314
|
+
const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
|
|
1089
1315
|
return [...bookings.values()]
|
|
1090
|
-
.filter((b) => b.
|
|
1316
|
+
.filter((b) => b.owners.has(who.sessionId))
|
|
1091
1317
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1092
|
-
.slice(0,
|
|
1093
|
-
.map(
|
|
1318
|
+
.slice(0, most)
|
|
1319
|
+
.map(publicBooking);
|
|
1094
1320
|
},
|
|
1095
1321
|
async findBooking(ref, who) {
|
|
1096
1322
|
sweep();
|
|
1097
1323
|
const b = findBooking(ref, who);
|
|
1098
|
-
return b ?
|
|
1324
|
+
return b ? publicBooking(b) : null;
|
|
1099
1325
|
},
|
|
1100
1326
|
/**
|
|
1101
1327
|
* What changing the nights or the room count would do.
|
|
@@ -1129,7 +1355,7 @@ export const createDemoHotels = (opts) => {
|
|
|
1129
1355
|
changedAt: new Date(now()).toISOString(),
|
|
1130
1356
|
};
|
|
1131
1357
|
bookings.set(b.id, updated);
|
|
1132
|
-
return
|
|
1358
|
+
return publicBooking(updated);
|
|
1133
1359
|
},
|
|
1134
1360
|
async previewCancellation(bookingRef, who) {
|
|
1135
1361
|
sweep();
|
|
@@ -1158,13 +1384,11 @@ export const createDemoHotels = (opts) => {
|
|
|
1158
1384
|
},
|
|
1159
1385
|
async cancelBooking(bookingRef, who, reason) {
|
|
1160
1386
|
sweep();
|
|
1161
|
-
const
|
|
1162
|
-
const b = [...bookings.values()].find((x) => (x.id.toLowerCase() === wanted || x.confirmationCode.toLowerCase() === wanted) &&
|
|
1163
|
-
mine(x, who));
|
|
1387
|
+
const b = findBooking(bookingRef, who);
|
|
1164
1388
|
if (!b)
|
|
1165
1389
|
return null;
|
|
1166
1390
|
if (b.status !== 'requested' && b.status !== 'confirmed')
|
|
1167
|
-
return
|
|
1391
|
+
return publicBooking(b);
|
|
1168
1392
|
const { fee } = feeFor(b, now());
|
|
1169
1393
|
b.status = 'cancelled';
|
|
1170
1394
|
b.cancelledAt = new Date(now()).toISOString();
|
|
@@ -1174,7 +1398,7 @@ export const createDemoHotels = (opts) => {
|
|
|
1174
1398
|
...b.guest,
|
|
1175
1399
|
notes: [b.guest.notes, `Cancelled: ${reason}`].filter(Boolean).join(' | '),
|
|
1176
1400
|
};
|
|
1177
|
-
return
|
|
1401
|
+
return publicBooking(b);
|
|
1178
1402
|
},
|
|
1179
1403
|
async policies(hotelId) {
|
|
1180
1404
|
const h = byId(hotelId);
|