@curless/hotel-storefront 0.27.7 → 0.27.9
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/README.md +1 -1
- package/dist/aggregate/hotels.d.ts +45 -0
- package/dist/aggregate/hotels.d.ts.map +1 -0
- package/dist/aggregate/hotels.js +633 -0
- package/dist/aggregate/hotels.js.map +1 -0
- package/dist/aggregate/ids.d.ts +36 -0
- package/dist/aggregate/ids.d.ts.map +1 -0
- package/dist/aggregate/ids.js +64 -0
- package/dist/aggregate/ids.js.map +1 -0
- package/dist/aggregate/make.d.ts +12 -0
- package/dist/aggregate/make.d.ts.map +1 -0
- package/dist/aggregate/make.js +96 -0
- package/dist/aggregate/make.js.map +1 -0
- package/dist/aggregate/paging.d.ts +35 -0
- package/dist/aggregate/paging.d.ts.map +1 -0
- package/dist/aggregate/paging.js +94 -0
- package/dist/aggregate/paging.js.map +1 -0
- package/dist/backend.d.ts +41 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +2 -2
- package/dist/card.generated.d.ts.map +1 -1
- package/dist/card.generated.js +3 -3
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +31 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/demo-hotels.d.ts +6 -0
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +16 -6
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +2 -0
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/make.d.ts +4 -0
- package/dist/huazhu/make.d.ts.map +1 -0
- package/dist/huazhu/make.js +105 -0
- package/dist/huazhu/make.js.map +1 -0
- package/dist/index.js +0 -0
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +136 -51
- package/dist/legal.js.map +1 -1
- package/dist/lib.d.ts +2 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +2 -0
- package/dist/lib.js.map +1 -1
- package/dist/listing.d.ts +10 -0
- package/dist/listing.d.ts.map +1 -0
- package/dist/listing.js +43 -0
- package/dist/listing.js.map +1 -0
- package/dist/server.d.ts +10 -22
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +40 -153
- package/dist/server.js.map +1 -1
- package/dist/state.d.ts +8 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +18 -1
- package/dist/state.js.map +1 -1
- package/dist/supplier.d.ts +50 -0
- package/dist/supplier.d.ts.map +1 -0
- package/dist/supplier.js +57 -0
- package/dist/supplier.js.map +1 -0
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +37 -21
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +29 -23
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +208 -69
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +20 -0
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +22 -1
- package/dist/vocab.js.map +1 -1
- package/package.json +12 -10
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { DEFAULT_PAGE, FULL_CAPABILITIES, MAX_PAGE, } from '../backend.js';
|
|
2
|
+
import { NAMESPACE, shownFor, unwrap, wrap } from './ids.js';
|
|
3
|
+
import { mergeKnown, pagePlan } from './paging.js';
|
|
4
|
+
/** Codes remembered at once; the oldest go first. A code that is forgotten is still found by asking. */
|
|
5
|
+
const MAX_ROUTES = 50_000;
|
|
6
|
+
/**
|
|
7
|
+
* Which supplier holds the booking a code names.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ A CODE IS WHAT THE GUEST QUOTES, AND IT IS NOT RENAMED: it is the hotel's
|
|
10
|
+
* handle on the stay, printed on a confirmation. So a code reaches the market
|
|
11
|
+
* without a namespace, and this is how it finds its way back. Two suppliers
|
|
12
|
+
* can in principle mint the same code; the one that made it is asked first.
|
|
13
|
+
*/
|
|
14
|
+
class Routes {
|
|
15
|
+
file;
|
|
16
|
+
codes = new Map();
|
|
17
|
+
/** What each supplier's codes have been seen to start with - "SL-". */
|
|
18
|
+
prefixes = new Map();
|
|
19
|
+
constructor(file) {
|
|
20
|
+
this.file = file;
|
|
21
|
+
const saved = file?.read();
|
|
22
|
+
for (const [code, ns] of Object.entries(saved?.codes ?? {}))
|
|
23
|
+
this.learn(code, ns);
|
|
24
|
+
file?.bind(() => ({ codes: Object.fromEntries(this.codes) }));
|
|
25
|
+
}
|
|
26
|
+
get(code) {
|
|
27
|
+
return this.codes.get(code.trim().toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
note(code, ns) {
|
|
30
|
+
const key = code.trim().toUpperCase();
|
|
31
|
+
if (!key || this.codes.get(key) === ns)
|
|
32
|
+
return;
|
|
33
|
+
this.learn(key, ns);
|
|
34
|
+
while (this.codes.size > MAX_ROUTES) {
|
|
35
|
+
const [oldest] = this.codes.keys();
|
|
36
|
+
if (oldest === undefined)
|
|
37
|
+
break;
|
|
38
|
+
this.codes.delete(oldest);
|
|
39
|
+
}
|
|
40
|
+
this.file?.changed();
|
|
41
|
+
}
|
|
42
|
+
learn(code, ns) {
|
|
43
|
+
const key = code.trim().toUpperCase();
|
|
44
|
+
this.codes.delete(key);
|
|
45
|
+
this.codes.set(key, ns);
|
|
46
|
+
const prefix = /^[A-Z0-9]{1,8}-/.exec(key)?.[0];
|
|
47
|
+
if (prefix)
|
|
48
|
+
this.prefixes.set(ns, (this.prefixes.get(ns) ?? new Set()).add(prefix));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A refusal the tools read back to the guest - see `guestWords` in tools.ts. */
|
|
52
|
+
const refusal = (code, message) => Object.assign(new Error(message), { code });
|
|
53
|
+
/**
|
|
54
|
+
* The market's identity for one supplier: everything but a member.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ A MEMBERSHIP IS ONE GROUP'S. The market signs nobody in (v1), but should a
|
|
57
|
+
* member ever ride along, one group's tier must not price another group's
|
|
58
|
+
* rooms - that is a discount neither group offered.
|
|
59
|
+
*/
|
|
60
|
+
const plain = (who) => {
|
|
61
|
+
if (!who)
|
|
62
|
+
return undefined;
|
|
63
|
+
const { member: _member, ...rest } = who;
|
|
64
|
+
return rest;
|
|
65
|
+
};
|
|
66
|
+
const listWords = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : (xs[0] ?? '');
|
|
67
|
+
/**
|
|
68
|
+
* What the market as a whole can do: anything one of its suppliers can, so a
|
|
69
|
+
* tool is listed when some hotel here offers it - and never members (v1: two
|
|
70
|
+
* groups' programmes, and nothing in the tools that says which).
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ `booking` CAN ONLY BE ONE WORD HERE, and a market can hold both. Until the
|
|
73
|
+
* tools read each hotel's own (香格里拉's step ③), a market that mixes them
|
|
74
|
+
* says `hold` - the one that promises less.
|
|
75
|
+
*/
|
|
76
|
+
export const marketCapabilities = (all) => ({
|
|
77
|
+
members: false,
|
|
78
|
+
extras: all.some((c) => c.extras),
|
|
79
|
+
changes: all.some((c) => c.changes),
|
|
80
|
+
booking: all.length > 0 && all.every((c) => c.booking === 'pay-at-hotel') ? 'pay-at-hotel' : 'hold',
|
|
81
|
+
...(all.some((c) => c.policies !== false) ? {} : { policies: false }),
|
|
82
|
+
...(all.some((c) => c.requiresPhone) ? { requiresPhone: true } : {}),
|
|
83
|
+
// One currency for EVERY hotel: two groups that each charge in one may not
|
|
84
|
+
// charge in the same one.
|
|
85
|
+
...(all.length === 1 && all[0]?.oneCurrency ? { oneCurrency: true } : {}),
|
|
86
|
+
});
|
|
87
|
+
const clampPage = (raw) => Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(raw) > 0 ? Number(raw) : DEFAULT_PAGE)));
|
|
88
|
+
const clampOffset = (raw) => Math.max(0, Math.floor(Number(raw) > 0 ? Number(raw) : 0));
|
|
89
|
+
/** The number a price order compares: the stay's total on named nights, the from-rate otherwise. */
|
|
90
|
+
const priceOf = (h) => h.forStay?.total.minor ?? h.fromNightly?.minor;
|
|
91
|
+
/** A hotel with no price, or no rating, goes last whichever way - as in every supplier's own order. */
|
|
92
|
+
const orderBy = (sort) => (a, b) => {
|
|
93
|
+
if (sort === 'rating')
|
|
94
|
+
return (b.rating?.value ?? -1) - (a.rating?.value ?? -1);
|
|
95
|
+
const x = priceOf(a);
|
|
96
|
+
const y = priceOf(b);
|
|
97
|
+
if (x === undefined)
|
|
98
|
+
return y === undefined ? 0 : 1;
|
|
99
|
+
if (y === undefined)
|
|
100
|
+
return -1;
|
|
101
|
+
return sort === 'price-high' ? y - x : x - y;
|
|
102
|
+
};
|
|
103
|
+
const tally = (rows, key, name) => {
|
|
104
|
+
const out = new Map();
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
const k = key(row);
|
|
107
|
+
const was = out.get(k);
|
|
108
|
+
out.set(k, was ? { ...was, count: was.count + row.count } : { ...row });
|
|
109
|
+
}
|
|
110
|
+
return [...out.values()].sort((a, b) => b.count - a.count || name(a).localeCompare(name(b)));
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* A market's supplier ids, checked before anything is made from them: each
|
|
114
|
+
* goes on every id the market hands out and names a state directory.
|
|
115
|
+
*/
|
|
116
|
+
export const checkSupplierIds = (brand, ids) => {
|
|
117
|
+
if (ids.length === 0)
|
|
118
|
+
throw new Error(`${brand}: a market needs at least one supplier`);
|
|
119
|
+
for (const id of ids) {
|
|
120
|
+
if (!NAMESPACE.test(id)) {
|
|
121
|
+
throw new Error(`${brand}: supplier id "${id}" must be lower-case letters, digits and dashes - it goes on every id and names a directory`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
125
|
+
if (dup)
|
|
126
|
+
throw new Error(`${brand}: two suppliers are called "${dup}"`);
|
|
127
|
+
};
|
|
128
|
+
export const createAggregateHotels = (opts) => {
|
|
129
|
+
const suppliers = opts.suppliers;
|
|
130
|
+
checkSupplierIds(opts.brand, suppliers.map((s) => s.id));
|
|
131
|
+
const known = new Set(suppliers.map((s) => s.id));
|
|
132
|
+
const byId = new Map(suppliers.map((s) => [s.id, s]));
|
|
133
|
+
/** One supplier's own capabilities, its programme and code prefix with them - but never members (v1). */
|
|
134
|
+
const capsOf = (s) => ({
|
|
135
|
+
...(s.backend.capabilities ?? FULL_CAPABILITIES),
|
|
136
|
+
members: false,
|
|
137
|
+
});
|
|
138
|
+
const prefixOf = (s) => {
|
|
139
|
+
const p = (s.codePrefix ?? s.backend.capabilities?.codePrefix)?.toUpperCase();
|
|
140
|
+
return p ? (p.endsWith('-') ? p : `${p}-`) : undefined;
|
|
141
|
+
};
|
|
142
|
+
const capabilities = marketCapabilities(suppliers.map(capsOf));
|
|
143
|
+
const routes = new Routes(opts.routes);
|
|
144
|
+
/** The supplier `filters.supplier` names - by id or by name, as a guest or a model would say it. */
|
|
145
|
+
const supplierNamed = (raw) => {
|
|
146
|
+
const wanted = raw.trim().toLowerCase();
|
|
147
|
+
return suppliers.find((s) => s.id === wanted || s.name.toLowerCase() === wanted);
|
|
148
|
+
};
|
|
149
|
+
const hotelOut = (ns, h) => ({
|
|
150
|
+
...h,
|
|
151
|
+
id: wrap(ns, h.id),
|
|
152
|
+
supplier: ns,
|
|
153
|
+
});
|
|
154
|
+
const lineOut = (ns, l) => ({ ...l, hotelId: wrap(ns, l.hotelId) });
|
|
155
|
+
const quoteOut = (ns, q) => ({
|
|
156
|
+
...q,
|
|
157
|
+
lines: q.lines.map((l) => lineOut(ns, l)),
|
|
158
|
+
});
|
|
159
|
+
const bookingOut = (ns, b) => {
|
|
160
|
+
routes.note(b.confirmationCode, ns);
|
|
161
|
+
return {
|
|
162
|
+
...b,
|
|
163
|
+
id: wrap(ns, b.id),
|
|
164
|
+
hotelId: wrap(ns, b.hotelId),
|
|
165
|
+
quote: quoteOut(ns, b.quote),
|
|
166
|
+
supplier: ns,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* The supplier a hotel reference belongs to, and its own id there.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ A NAME IS LOOKED UP IN EVERY GROUP AND TAKEN ONLY WHEN IT IS ONE HOTEL.
|
|
173
|
+
* Each backend finds a hotel by its name as well as its id, loosely; asked
|
|
174
|
+
* across groups, "Shanghai" can be two hotels in two groups, and picking
|
|
175
|
+
* either is booking the guest somewhere they did not choose. Exactly one, or
|
|
176
|
+
* an exact name, or nothing.
|
|
177
|
+
*/
|
|
178
|
+
const hotelRef = async (ref) => {
|
|
179
|
+
const own = unwrap(ref, known);
|
|
180
|
+
if (own) {
|
|
181
|
+
const s = byId.get(own.ns);
|
|
182
|
+
return s ? { s, id: own.id } : undefined;
|
|
183
|
+
}
|
|
184
|
+
const found = await Promise.all(suppliers.map(async (s) => {
|
|
185
|
+
try {
|
|
186
|
+
const d = await s.backend.getHotel(ref);
|
|
187
|
+
return d ? { s, hotel: d.hotel } : undefined;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}));
|
|
193
|
+
const hits = found.filter((x) => !!x);
|
|
194
|
+
const exact = hits.filter((x) => x.hotel.name.toLowerCase() === ref.trim().toLowerCase());
|
|
195
|
+
const one = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
|
|
196
|
+
return one ? { s: one.s, id: one.hotel.id } : undefined;
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* The suppliers that may hold a booking a reference names, most likely
|
|
200
|
+
* first: the one a namespace names; else the one that made the code, then
|
|
201
|
+
* those whose codes look like it - and only when none do, all of them.
|
|
202
|
+
*/
|
|
203
|
+
const bookingCandidates = (ref) => {
|
|
204
|
+
const own = unwrap(ref, known);
|
|
205
|
+
if (own) {
|
|
206
|
+
const s = byId.get(own.ns);
|
|
207
|
+
return s ? [{ s, ref: own.id }] : [];
|
|
208
|
+
}
|
|
209
|
+
const code = ref.trim().toUpperCase();
|
|
210
|
+
const prefixes = (s) => [
|
|
211
|
+
...(prefixOf(s) ? [prefixOf(s)] : []),
|
|
212
|
+
...(routes.prefixes.get(s.id) ?? []),
|
|
213
|
+
];
|
|
214
|
+
const mapped = routes.get(code);
|
|
215
|
+
const looksLike = suppliers.filter((s) => prefixes(s).some((p) => code.startsWith(p)));
|
|
216
|
+
const first = [
|
|
217
|
+
...(mapped && byId.get(mapped) ? [byId.get(mapped)] : []),
|
|
218
|
+
...looksLike,
|
|
219
|
+
];
|
|
220
|
+
const order = first.length ? first : suppliers;
|
|
221
|
+
return [...new Set(order)].map((s) => ({ s, ref }));
|
|
222
|
+
};
|
|
223
|
+
/** The first supplier, most likely first, that answers for this booking reference. */
|
|
224
|
+
const firstAnswer = async (ref, ask) => {
|
|
225
|
+
for (const c of bookingCandidates(ref)) {
|
|
226
|
+
const value = await ask(c.s, c.ref);
|
|
227
|
+
if (value)
|
|
228
|
+
return { s: c.s, value };
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
/** A trip's lines by supplier, each with the market's prefix taken off. Lines naming no supplier here, apart. */
|
|
233
|
+
const splitTrip = (lines) => {
|
|
234
|
+
const parts = new Map();
|
|
235
|
+
const strays = [];
|
|
236
|
+
for (const l of lines) {
|
|
237
|
+
const own = unwrap(l.hotelId, known);
|
|
238
|
+
if (!own) {
|
|
239
|
+
strays.push(l);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
parts.set(own.ns, [...(parts.get(own.ns) ?? []), { ...l, hotelId: own.id }]);
|
|
243
|
+
}
|
|
244
|
+
return { parts, strays };
|
|
245
|
+
};
|
|
246
|
+
const draftFor = (ns, lines, trip) => ({
|
|
247
|
+
lines,
|
|
248
|
+
...(trip.shown ? { shown: shownFor(trip.shown, ns, known) } : {}),
|
|
249
|
+
});
|
|
250
|
+
/**
|
|
251
|
+
* A change this booking's group cannot make, answered as one - or nothing,
|
|
252
|
+
* when it can. Nights, rooms and the room type are `changes`; an add-on is
|
|
253
|
+
* `extras`.
|
|
254
|
+
*/
|
|
255
|
+
const unchangeable = (s, b, change) => {
|
|
256
|
+
const caps = capsOf(s);
|
|
257
|
+
const stayAsked = change.checkIn !== undefined ||
|
|
258
|
+
change.checkOut !== undefined ||
|
|
259
|
+
change.rooms !== undefined ||
|
|
260
|
+
change.room !== undefined;
|
|
261
|
+
const extraAsked = change.extra !== undefined;
|
|
262
|
+
const rule = stayAsked && !caps.changes
|
|
263
|
+
? `${s.name} cannot change a booking in place: for other nights or rooms, cancel this one and book again - the cancellation follows its own terms.`
|
|
264
|
+
: extraAsked && !caps.extras
|
|
265
|
+
? `${s.name} sells no add-ons here, so none can be put on this booking.`
|
|
266
|
+
: undefined;
|
|
267
|
+
if (!rule)
|
|
268
|
+
return null;
|
|
269
|
+
const room = b.quote.lines.find((l) => l.kind === 'room');
|
|
270
|
+
return {
|
|
271
|
+
bookingId: wrap(s.id, b.id),
|
|
272
|
+
confirmationCode: b.confirmationCode,
|
|
273
|
+
hotelName: b.hotelName,
|
|
274
|
+
from: {
|
|
275
|
+
stay: b.stay,
|
|
276
|
+
rooms: room?.quantity ?? 1,
|
|
277
|
+
roomName: room?.title ?? '',
|
|
278
|
+
total: b.quote.total,
|
|
279
|
+
},
|
|
280
|
+
changeable: false,
|
|
281
|
+
rule,
|
|
282
|
+
supplier: s.id,
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
const searchHotels = async (q, who) => {
|
|
286
|
+
const asked = q.filters;
|
|
287
|
+
const { supplier: wantedName, ...rest } = asked ?? {};
|
|
288
|
+
const wanted = wantedName ? supplierNamed(wantedName) : undefined;
|
|
289
|
+
const filters = asked && Object.keys(rest).length > 0 ? rest : undefined;
|
|
290
|
+
const from = clampOffset(q.offset);
|
|
291
|
+
const page = clampPage(q.limit);
|
|
292
|
+
const sort = q.sort ?? 'group';
|
|
293
|
+
const ordered = sort !== 'group';
|
|
294
|
+
const active = wantedName ? (wanted ? [wanted] : []) : suppliers;
|
|
295
|
+
const others = suppliers.filter((s) => !active.includes(s));
|
|
296
|
+
const caller = plain(who);
|
|
297
|
+
const unapplied = [];
|
|
298
|
+
if (wantedName && !wanted) {
|
|
299
|
+
unapplied.push(`No hotel group here is called "${wantedName}" - the groups are ${listWords(suppliers.map((s) => s.name))}.`);
|
|
300
|
+
}
|
|
301
|
+
let base = { ...q, filters, offset: 0 };
|
|
302
|
+
const n = Math.max(1, active.length);
|
|
303
|
+
// The first ask: enough of each supplier's list for this page when the
|
|
304
|
+
// suppliers take turns (`ordered` false), or each one's first
|
|
305
|
+
// `from + page`, capped, when one order runs across them.
|
|
306
|
+
const firstWindow = ordered
|
|
307
|
+
? { offset: 0, limit: Math.min(from + page, MAX_PAGE) }
|
|
308
|
+
: { offset: Math.floor(from / n), limit: Math.min(MAX_PAGE, Math.ceil(page / n) + 1) };
|
|
309
|
+
const ask = async (s, window) => s.backend.searchHotels({ ...base, ...window }, caller);
|
|
310
|
+
const round = async (list, window) => Promise.allSettled(list.map((s) => ask(s, window)));
|
|
311
|
+
let first = await round(active, firstWindow);
|
|
312
|
+
let counts = await round(others, { offset: 0, limit: 1 });
|
|
313
|
+
const okOf = (settled, list) => settled.flatMap((r, i) => {
|
|
314
|
+
const s = list[i];
|
|
315
|
+
return r.status === 'fulfilled' && s ? [{ s, k: i, result: r.value }] : [];
|
|
316
|
+
});
|
|
317
|
+
let answers = okOf(first, active);
|
|
318
|
+
// ⚠️ A PRICE CEILING IS IN ONE CURRENCY, AND EACH SUPPLIER APPLIED IT IN
|
|
319
|
+
// ITS OWN. Huazhu applying ¥800 and a Tokyo group applying ¥800 are two
|
|
320
|
+
// different ceilings with one number. Across currencies the market applies
|
|
321
|
+
// none - asked again without it - and the tools say so, as for one group.
|
|
322
|
+
const union = (rs) => [...new Set(rs.flatMap((a) => a.result.currencies))].sort();
|
|
323
|
+
if (Number(q.maxNightly) > 0 && union(answers).length > 1) {
|
|
324
|
+
const { maxNightly: _dropped, ...without } = base;
|
|
325
|
+
base = without;
|
|
326
|
+
first = await round(active, firstWindow);
|
|
327
|
+
counts = await round(others, { offset: 0, limit: 1 });
|
|
328
|
+
answers = okOf(first, active);
|
|
329
|
+
}
|
|
330
|
+
const down = first.flatMap((r, i) => (r.status === 'rejected' ? [active[i]] : []));
|
|
331
|
+
if (active.length > 0 && answers.length === 0) {
|
|
332
|
+
throw first.find((r) => r.status === 'rejected').reason;
|
|
333
|
+
}
|
|
334
|
+
for (const s of down) {
|
|
335
|
+
if (s)
|
|
336
|
+
unapplied.push(`${s.name}'s hotels could not be searched just now, so none of them is in this list.`);
|
|
337
|
+
}
|
|
338
|
+
if (down.length)
|
|
339
|
+
console.error('[market] search failed for', down.map((s) => s?.id).join(', '));
|
|
340
|
+
const currencies = union(answers);
|
|
341
|
+
const totals = active.map((s) => answers.find((a) => a.s === s)?.result.total ?? 0);
|
|
342
|
+
const got = active.map(() => new Map());
|
|
343
|
+
for (const a of answers) {
|
|
344
|
+
a.result.hotels.forEach((h, j) => got[a.k]?.set(firstWindow.offset + j, h));
|
|
345
|
+
}
|
|
346
|
+
// One order across suppliers, where it means something: a price order in
|
|
347
|
+
// one currency, or ratings - and only where every supplier applied it to
|
|
348
|
+
// its own list, because merging lists that are not in order is not an
|
|
349
|
+
// order.
|
|
350
|
+
const childIgnored = answers.some((a) => a.result.ignored?.sort);
|
|
351
|
+
const merging = ordered && !childIgnored && (sort === 'rating' || currencies.length <= 1);
|
|
352
|
+
const known = merging
|
|
353
|
+
? mergeKnown(active.map((_, k) => {
|
|
354
|
+
const items = [];
|
|
355
|
+
for (let i = 0; got[k]?.has(i); i += 1)
|
|
356
|
+
items.push(got[k]?.get(i));
|
|
357
|
+
return items;
|
|
358
|
+
}), totals, orderBy(sort))
|
|
359
|
+
: [];
|
|
360
|
+
// Biggest first when they take turns: the group with more of what was
|
|
361
|
+
// asked for opens the list. Ties keep the market's own order.
|
|
362
|
+
const turns = active
|
|
363
|
+
.map((_, k) => k)
|
|
364
|
+
.sort((a, b) => (totals[b] ?? 0) - (totals[a] ?? 0) || a - b);
|
|
365
|
+
const { slots, beyond } = pagePlan(known, totals, turns, from, page);
|
|
366
|
+
if (beyond) {
|
|
367
|
+
unapplied.push(`Past the first ${known.length}, hotels are in that order within each group and the groups take turns - ask with a city or a filter for one order all the way down.`);
|
|
368
|
+
}
|
|
369
|
+
// What the plan needs and the first ask did not bring.
|
|
370
|
+
for (let k = 0; k < active.length; k += 1) {
|
|
371
|
+
const need = slots.filter((x) => x.k === k && !got[k]?.has(x.i)).map((x) => x.i);
|
|
372
|
+
if (need.length === 0)
|
|
373
|
+
continue;
|
|
374
|
+
const s = active[k];
|
|
375
|
+
for (let lo = Math.min(...need), hi = Math.max(...need); lo <= hi; lo += MAX_PAGE) {
|
|
376
|
+
const limit = Math.min(MAX_PAGE, hi - lo + 1);
|
|
377
|
+
try {
|
|
378
|
+
const more = await ask(s, { offset: lo, limit });
|
|
379
|
+
more.hotels.forEach((h, j) => got[k]?.set(lo + j, h));
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
console.error(`[market] ${s.id} page from ${lo} failed:`, err);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const hotels = slots.flatMap((x) => {
|
|
387
|
+
const h = got[x.k]?.get(x.i);
|
|
388
|
+
const s = active[x.k];
|
|
389
|
+
return h && s ? [hotelOut(s.id, h)] : [];
|
|
390
|
+
});
|
|
391
|
+
const total = totals.reduce((a, b) => a + b, 0);
|
|
392
|
+
const results = answers.map((a) => a.result);
|
|
393
|
+
const sum = (f) => results.reduce((acc, r) => acc + (f(r) ?? 0), 0);
|
|
394
|
+
const refines = results.flatMap((r) => (r.refine ? [r.refine] : []));
|
|
395
|
+
const otherAnswers = okOf(counts, others);
|
|
396
|
+
const supplierCounts = suppliers.flatMap((s) => {
|
|
397
|
+
const a = answers.find((x) => x.s === s) ?? otherAnswers.find((x) => x.s === s);
|
|
398
|
+
return a ? [{ id: s.id, count: a.result.total }] : [];
|
|
399
|
+
});
|
|
400
|
+
const anyFilter = !!asked && Object.keys(asked).length > 0;
|
|
401
|
+
const nearest = total === 0
|
|
402
|
+
? results
|
|
403
|
+
.flatMap((r, j) => {
|
|
404
|
+
const s = answers[j]?.s;
|
|
405
|
+
return s ? (r.nearest ?? []).map((h) => hotelOut(s.id, h)) : [];
|
|
406
|
+
})
|
|
407
|
+
.sort(currencies.length <= 1 ? orderBy('price-low') : () => 0)
|
|
408
|
+
.slice(0, 3)
|
|
409
|
+
: [];
|
|
410
|
+
const ignored = {
|
|
411
|
+
...(results.some((r) => r.ignored?.sort) ? { sort: true } : {}),
|
|
412
|
+
...(results.some((r) => r.ignored?.ceiling) ? { ceiling: true } : {}),
|
|
413
|
+
...(results.some((r) => r.ignored?.roomTerms) ? { roomTerms: true } : {}),
|
|
414
|
+
};
|
|
415
|
+
const said = [...new Set([...results.flatMap((r) => r.unapplied ?? []), ...unapplied])];
|
|
416
|
+
return {
|
|
417
|
+
hotels,
|
|
418
|
+
total,
|
|
419
|
+
currencies,
|
|
420
|
+
refine: {
|
|
421
|
+
brands: tally(refines.flatMap((r) => r.brands), (b) => b.name, (b) => b.name),
|
|
422
|
+
facilities: tally(refines.flatMap((r) => r.facilities), (f) => f.key, (f) => f.key),
|
|
423
|
+
freeCancellation: refines.reduce((a, r) => a + r.freeCancellation, 0),
|
|
424
|
+
breakfast: refines.reduce((a, r) => a + r.breakfast, 0),
|
|
425
|
+
suppliers: supplierCounts,
|
|
426
|
+
},
|
|
427
|
+
...(anyFilter
|
|
428
|
+
? {
|
|
429
|
+
beforeFilters: [...answers, ...otherAnswers].reduce((a, x) => a + (x.result.beforeFilters ?? x.result.total), 0),
|
|
430
|
+
}
|
|
431
|
+
: {}),
|
|
432
|
+
...(results.some((r) => r.beforeCeiling !== undefined)
|
|
433
|
+
? { beforeCeiling: sum((r) => r.beforeCeiling ?? r.total) }
|
|
434
|
+
: {}),
|
|
435
|
+
facets: {
|
|
436
|
+
countries: tally(results.flatMap((r) => r.facets?.countries ?? []), (c) => c.name, (c) => c.name),
|
|
437
|
+
cities: tally(results.flatMap((r) => r.facets?.cities ?? []), (c) => `${c.name}|${c.country ?? ''}`, (c) => c.name),
|
|
438
|
+
},
|
|
439
|
+
...(sum((r) => r.overBudget) > 0 ? { overBudget: sum((r) => r.overBudget) } : {}),
|
|
440
|
+
...(nearest.length ? { nearest } : {}),
|
|
441
|
+
...(said.length ? { unapplied: said } : {}),
|
|
442
|
+
...(Object.keys(ignored).length ? { ignored } : {}),
|
|
443
|
+
};
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
brand: opts.brand,
|
|
447
|
+
capabilities,
|
|
448
|
+
suppliers: () => suppliers.map((s) => ({
|
|
449
|
+
id: s.id,
|
|
450
|
+
name: s.name,
|
|
451
|
+
...(s.logoUrl ? { logoUrl: s.logoUrl } : {}),
|
|
452
|
+
mode: s.mode,
|
|
453
|
+
capabilities: capsOf(s),
|
|
454
|
+
})),
|
|
455
|
+
supplierOf: (hotelId) => unwrap(hotelId, known)?.ns,
|
|
456
|
+
searchHotels,
|
|
457
|
+
async getHotel(id, stay, who) {
|
|
458
|
+
const at = await hotelRef(id);
|
|
459
|
+
if (!at)
|
|
460
|
+
return null;
|
|
461
|
+
const d = await at.s.backend.getHotel(at.id, stay, plain(who));
|
|
462
|
+
return d ? { ...d, hotel: hotelOut(at.s.id, d.hotel) } : null;
|
|
463
|
+
},
|
|
464
|
+
async datesAround(id, stay, o, who) {
|
|
465
|
+
const at = await hotelRef(id);
|
|
466
|
+
return at ? at.s.backend.datesAround(at.id, stay, o, plain(who)) : [];
|
|
467
|
+
},
|
|
468
|
+
// ⚠️ NO MEMBERS IN THE MARKET (v1) - `capabilities.members` is false, so
|
|
469
|
+
// the tools never list a sign-in. Answered anyway, as nobody.
|
|
470
|
+
async signInMember() {
|
|
471
|
+
return null;
|
|
472
|
+
},
|
|
473
|
+
async rememberPreferences() {
|
|
474
|
+
return [];
|
|
475
|
+
},
|
|
476
|
+
// A group that sells no add-ons is not asked for them.
|
|
477
|
+
async extrasFor(hotelId, stay) {
|
|
478
|
+
const at = await hotelRef(hotelId);
|
|
479
|
+
if (!at || !capsOf(at.s).extras)
|
|
480
|
+
return [];
|
|
481
|
+
const extras = await at.s.backend.extrasFor(at.id, stay);
|
|
482
|
+
return extras.map((x) => ({ ...x, hotelId: wrap(at.s.id, x.hotelId) }));
|
|
483
|
+
},
|
|
484
|
+
/**
|
|
485
|
+
* A trip priced. The tools price one currency at a time, and one currency
|
|
486
|
+
* can be two groups' hotels - Shanghai is yuan at both - so each group
|
|
487
|
+
* prices its own lines and the totals are added: same currency, no rate.
|
|
488
|
+
*/
|
|
489
|
+
async quote(trip, who) {
|
|
490
|
+
const { parts, strays } = splitTrip(trip.lines);
|
|
491
|
+
const caller = plain(who);
|
|
492
|
+
if (parts.size === 0) {
|
|
493
|
+
// An empty trip, or one whose hotels are no longer here.
|
|
494
|
+
const q = await suppliers[0].backend.quote({ lines: [] }, caller);
|
|
495
|
+
return strays.length
|
|
496
|
+
? {
|
|
497
|
+
...q,
|
|
498
|
+
dropped: strays.map((l) => ({
|
|
499
|
+
title: l.hotelId,
|
|
500
|
+
why: 'That hotel is not in this market any more.',
|
|
501
|
+
})),
|
|
502
|
+
}
|
|
503
|
+
: q;
|
|
504
|
+
}
|
|
505
|
+
const quotes = await Promise.all([...parts.entries()].map(async ([ns, lines]) => quoteOut(ns, await byId.get(ns).backend.quote(draftFor(ns, lines, trip), caller))));
|
|
506
|
+
const dropped = [
|
|
507
|
+
...quotes.flatMap((q) => q.dropped ?? []),
|
|
508
|
+
...strays.map((l) => ({
|
|
509
|
+
title: l.hotelId,
|
|
510
|
+
why: 'That hotel is not in this market any more.',
|
|
511
|
+
})),
|
|
512
|
+
];
|
|
513
|
+
const [one] = quotes;
|
|
514
|
+
if (quotes.length === 1 && one)
|
|
515
|
+
return dropped.length ? { ...one, dropped } : one;
|
|
516
|
+
const currency = one?.total.currency ?? '';
|
|
517
|
+
if (quotes.some((q) => q.total.currency !== currency)) {
|
|
518
|
+
throw refusal('mixed-currency', `These hotels charge in ${listWords([...new Set(quotes.map((q) => q.total.currency))])}; a trip is priced one currency at a time.`);
|
|
519
|
+
}
|
|
520
|
+
const add = (f) => quotes.reduce((a, q) => a + f(q), 0);
|
|
521
|
+
// Taxes given apart by one group and included by the other cannot be
|
|
522
|
+
// shown as one split: the whole is said to include them, which is true.
|
|
523
|
+
const included = quotes.some((q) => q.taxesIncluded);
|
|
524
|
+
const stays = quotes.map((q) => q.stay);
|
|
525
|
+
const sameStay = stays.every((s) => s) &&
|
|
526
|
+
new Set(stays.map((s) => `${s?.checkIn}|${s?.checkOut}|${s?.guests}`)).size === 1;
|
|
527
|
+
return {
|
|
528
|
+
lines: quotes.flatMap((q) => q.lines),
|
|
529
|
+
subtotal: {
|
|
530
|
+
minor: included ? add((q) => q.total.minor) : add((q) => q.subtotal.minor),
|
|
531
|
+
currency,
|
|
532
|
+
},
|
|
533
|
+
taxesAndFees: { minor: included ? 0 : add((q) => q.taxesAndFees.minor), currency },
|
|
534
|
+
...(included ? { taxesIncluded: true } : {}),
|
|
535
|
+
total: { minor: add((q) => q.total.minor), currency },
|
|
536
|
+
...(sameStay && stays[0] ? { stay: stays[0] } : {}),
|
|
537
|
+
...(dropped.length ? { dropped } : {}),
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
/**
|
|
541
|
+
* ⚠️ ONE GROUP PER BOOKING (v1). Each group holds or books under its own
|
|
542
|
+
* rules, its own limits and its own codes, and "all of them or none" -
|
|
543
|
+
* the rule every backend keeps for a trip - cannot be kept across two
|
|
544
|
+
* systems that do not know about each other: the second refusing after the
|
|
545
|
+
* first booked leaves half a trip. So a trip with two groups' hotels is
|
|
546
|
+
* refused whole, before anybody is asked.
|
|
547
|
+
*/
|
|
548
|
+
async requestBooking(trip, guest, who, requests) {
|
|
549
|
+
const { parts, strays } = splitTrip(trip.lines);
|
|
550
|
+
if (strays.length) {
|
|
551
|
+
throw refusal('not-found', 'Nothing was booked: a hotel in this trip is not in this market any more. Take it out of the trip and ask again.');
|
|
552
|
+
}
|
|
553
|
+
if (parts.size > 1) {
|
|
554
|
+
const groups = [...parts.entries()].map(([ns, lines]) => {
|
|
555
|
+
const hotels = new Set(lines.map((l) => l.hotelId)).size;
|
|
556
|
+
return `${hotels} ${hotels === 1 ? 'hotel' : 'hotels'} with ${byId.get(ns)?.name ?? ns}`;
|
|
557
|
+
});
|
|
558
|
+
throw refusal('mixed-supplier', `Nothing was booked: this trip has ${listWords(groups)}, and a booking here is made with one hotel group at a time. Take one group's hotels out of the trip, book the rest, then add them back and book them.`);
|
|
559
|
+
}
|
|
560
|
+
// An empty trip is the first supplier's to refuse, in its own words.
|
|
561
|
+
const [ns, lines] = [...parts.entries()][0] ?? [suppliers[0].id, []];
|
|
562
|
+
const made = await byId.get(ns).backend.requestBooking(draftFor(ns, lines, trip), guest, plain(who), requests);
|
|
563
|
+
return made.map((b) => bookingOut(ns, b));
|
|
564
|
+
},
|
|
565
|
+
/** Every group's, newest first - the order each keeps its own in. */
|
|
566
|
+
async listBookings(who, limit = 10) {
|
|
567
|
+
const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
|
|
568
|
+
const caller = plain(who);
|
|
569
|
+
const settled = await Promise.allSettled(suppliers.map((s) => s.backend.listBookings(caller, most)));
|
|
570
|
+
const failed = settled.filter((r) => r.status === 'rejected');
|
|
571
|
+
if (failed.length === settled.length)
|
|
572
|
+
throw failed[0].reason;
|
|
573
|
+
if (failed.length)
|
|
574
|
+
console.error('[market] listing bookings failed for', failed.length, 'supplier(s)');
|
|
575
|
+
return settled
|
|
576
|
+
.flatMap((r, i) => {
|
|
577
|
+
const s = suppliers[i];
|
|
578
|
+
return r.status === 'fulfilled' && s ? r.value.map((b) => bookingOut(s.id, b)) : [];
|
|
579
|
+
})
|
|
580
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
581
|
+
.slice(0, most);
|
|
582
|
+
},
|
|
583
|
+
async findBooking(ref, who) {
|
|
584
|
+
const caller = plain(who);
|
|
585
|
+
const hit = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
|
|
586
|
+
return hit ? bookingOut(hit.s.id, hit.value) : null;
|
|
587
|
+
},
|
|
588
|
+
/**
|
|
589
|
+
* ⚠️ A GROUP THAT CANNOT DO IT IS NOT ASKED. A market lists the change
|
|
590
|
+
* tools when any of its groups can change a booking; one that cannot is
|
|
591
|
+
* answered here, from its own booking, as a change that cannot be made -
|
|
592
|
+
* never with a call it has no answer for.
|
|
593
|
+
*/
|
|
594
|
+
async previewChange(ref, change, who) {
|
|
595
|
+
const caller = plain(who);
|
|
596
|
+
const found = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
|
|
597
|
+
if (!found)
|
|
598
|
+
return null;
|
|
599
|
+
const { s } = found;
|
|
600
|
+
const refused = unchangeable(s, found.value, change);
|
|
601
|
+
if (refused)
|
|
602
|
+
return refused;
|
|
603
|
+
const p = await s.backend.previewChange(found.value.id, change, caller);
|
|
604
|
+
return p ? { ...p, bookingId: wrap(s.id, p.bookingId), supplier: s.id } : null;
|
|
605
|
+
},
|
|
606
|
+
async changeBooking(ref, change, who) {
|
|
607
|
+
const caller = plain(who);
|
|
608
|
+
const found = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
|
|
609
|
+
if (!found || unchangeable(found.s, found.value, change))
|
|
610
|
+
return null;
|
|
611
|
+
const b = await found.s.backend.changeBooking(found.value.id, change, caller);
|
|
612
|
+
return b ? bookingOut(found.s.id, b) : null;
|
|
613
|
+
},
|
|
614
|
+
async previewCancellation(ref, who) {
|
|
615
|
+
const caller = plain(who);
|
|
616
|
+
const hit = await firstAnswer(ref, (s, r) => s.backend.previewCancellation(r, caller));
|
|
617
|
+
return hit
|
|
618
|
+
? { ...hit.value, bookingId: wrap(hit.s.id, hit.value.bookingId), supplier: hit.s.id }
|
|
619
|
+
: null;
|
|
620
|
+
},
|
|
621
|
+
async cancelBooking(ref, who, reason) {
|
|
622
|
+
const caller = plain(who);
|
|
623
|
+
const hit = await firstAnswer(ref, (s, r) => s.backend.cancelBooking(r, caller, reason));
|
|
624
|
+
return hit ? bookingOut(hit.s.id, hit.value) : null;
|
|
625
|
+
},
|
|
626
|
+
// A group that publishes no policy text is not asked for it.
|
|
627
|
+
async policies(hotelId) {
|
|
628
|
+
const at = await hotelRef(hotelId);
|
|
629
|
+
return at && capsOf(at.s).policies !== false ? at.s.backend.policies(at.id) : [];
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
//# sourceMappingURL=hotels.js.map
|