letsfg 2026.5.54 → 2026.5.55

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.
@@ -0,0 +1,1768 @@
1
+ // src/offer-details.ts
2
+ var MEAL_RE = /\b(meal|meals|meal service|hot meal|catering|breakfast|lunch|dinner|santan)\b/i;
3
+ var REFRESHMENT_RE = /\b(refreshment|refreshments|drink|drinks|beverage|beverages|snack|snacks|food and drink)\b/i;
4
+ var INSURANCE_RE = /\b(insurance|coverage|protection|travel insurance|disruption cover)\b/i;
5
+ var LOUNGE_RE = /\b(lounge|vip lounge|priority pass|airport lounge)\b/i;
6
+ var WIFI_RE = /\b(wi[ -]?fi|wifi|internet access|onboard internet|wireless internet)\b/i;
7
+ var POWER_RE = /\b(in[- ]?seat power|usb outlets?|usb ports?|usb power|power outlets?|power sockets?|ac power|seat power|charging ports?|charging outlets?)\b/i;
8
+ var ENTERTAINMENT_RE = /\b(in[- ]?flight entertainment|ife|seatback screen|entertainment screen|personal entertainment|stream(?:ing)? media(?: to your device)?|stream to your device|watch on your device)\b/i;
9
+ var WIFI_BARE_RE = /\b(wi[ -]?fi|wifi|internet access|onboard internet|wireless internet)\b/i;
10
+ var POWER_BARE_RE = /\b(in[- ]?seat power|usb outlets?|usb ports?|usb power|power outlets?|power sockets?|ac power|seat power|charging ports?|charging outlets?)\b/i;
11
+ var ENTERTAINMENT_BARE_RE = /\b(in[- ]?flight entertainment|ife|seatback screen|entertainment screen|personal entertainment|stream(?:ing)? media(?: to your device)?|stream to your device|watch on your device)\b/i;
12
+ var SERVICE_INCLUDED_RE = /\b(included?|incl\.?|includes?|including|with|complimentary|provided|free of charge|free)\b/i;
13
+ var SERVICE_AVAILABLE_RE = /\b(available|optional|option|add[- ]?on|extra|for a fee|with a fee|fee|charges may apply|buy[- ]?on[- ]?board|sold separately|upgrade)\b/i;
14
+ var SERVICE_UNAVAILABLE_RE = /\b(not available|unavailable|not offered|not included|not possible|sold[- ]?out)\b/i;
15
+ var REFUND_TEXT_RE = /\b(refund|refundable|cancell|cancel)\b/i;
16
+ var REFUND_NOT_ALLOWED_RE = /\b(non[- ]?refundable|not refundable|no refunds?|refunds? not allowed|cancellations? not allowed)\b/i;
17
+ var REFUND_WITH_FEE_RE = /\b(refund|refundable|cancell|cancel)\b.*\b(with fee|fee|penalty|charges may apply)\b/i;
18
+ var REFUND_ALLOWED_RE = /\b(refundable|refunds? allowed|cancellations? allowed|cancel(?:lation)? allowed|free of charge)\b/i;
19
+ var CHANGE_TEXT_RE = /\b(change|changes|changeable|rebook|rebooking)\b/i;
20
+ var CHANGE_NOT_ALLOWED_RE = /\b(no changes?|changes? not allowed|not changeable|not possible)\b/i;
21
+ var CHANGE_WITH_FEE_RE = /\b(change|changes|rebook|rebooking)\b.*\b(with fee|fee|penalty|charges may apply)\b/i;
22
+ var CHANGE_ALLOWED_RE = /\b(changes? allowed|changeable|rebook(?:ing)? allowed|free of charge)\b/i;
23
+ var SEAT_SELECTION_RE = /\b(seat selection|select your seat|choose your seat|choose seats?|seat choice|standard seat|extra legroom|preferred seat)\b/i;
24
+ var LEGROOM_RE = /\b(?:average\s+legroom|extra legroom|legroom|pitch)\b/i;
25
+ function normalizeDetailText(value) {
26
+ const withoutKey = value.replace(/^[a-z][a-z_ ]{0,40}:\s*/i, "");
27
+ return withoutKey.replace(/\s+/g, " ").trim();
28
+ }
29
+ function truncateDetailText(value, maxLength = 96) {
30
+ if (value.length <= maxLength) {
31
+ return value;
32
+ }
33
+ return `${value.slice(0, maxLength - 3).trimEnd()}...`;
34
+ }
35
+ function pushUniqueNote(notes, note) {
36
+ if (!note || notes.includes(note)) {
37
+ return;
38
+ }
39
+ notes.push(note);
40
+ }
41
+ function getConditionValue(offer, ...keys) {
42
+ for (const key of keys) {
43
+ const value = offer.conditions?.[key];
44
+ if (typeof value === "string" && value.trim().length > 0) {
45
+ return value.trim();
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ function summarizeConditionList(value, maxItems = 3) {
51
+ if (!value) {
52
+ return null;
53
+ }
54
+ const parts = splitDetailText(value).map((part) => normalizeDetailText(part)).filter((part) => part.length > 0);
55
+ if (parts.length === 0) {
56
+ return null;
57
+ }
58
+ return truncateDetailText(parts.slice(0, maxItems).join("; "));
59
+ }
60
+ function collectOfferSegments(offer) {
61
+ return [
62
+ ...offer.segments ?? [],
63
+ ...offer.inbound?.segments ?? []
64
+ ];
65
+ }
66
+ function collectUniqueDetailValues(values) {
67
+ const seen = /* @__PURE__ */ new Set();
68
+ const output = [];
69
+ for (const value of values) {
70
+ const normalized = typeof value === "string" ? value.trim() : "";
71
+ if (!normalized) {
72
+ continue;
73
+ }
74
+ const key = normalized.toLowerCase();
75
+ if (seen.has(key)) {
76
+ continue;
77
+ }
78
+ seen.add(key);
79
+ output.push(normalized);
80
+ }
81
+ return output;
82
+ }
83
+ function collectFlightNumbers(offer) {
84
+ const segmentFlightNumbers = collectOfferSegments(offer).map((segment) => segment.flight_number);
85
+ return collectUniqueDetailValues([
86
+ ...segmentFlightNumbers,
87
+ offer.flight_number
88
+ ]);
89
+ }
90
+ function collectAircraftTypes(offer) {
91
+ return collectUniqueDetailValues(
92
+ collectOfferSegments(offer).map((segment) => segment.aircraft?.replace(/\s*\([^)]*\)/g, "").trim())
93
+ );
94
+ }
95
+ function collectOperatingCarriers(offer) {
96
+ return collectUniqueDetailValues(
97
+ collectOfferSegments(offer).map((segment) => segment.airline)
98
+ );
99
+ }
100
+ function findMatchingSourceText(sources, pattern) {
101
+ for (const source of sources) {
102
+ if (!pattern.test(source.text)) {
103
+ continue;
104
+ }
105
+ const cleaned = normalizeDetailText(source.text);
106
+ if (cleaned.length > 0) {
107
+ return truncateDetailText(cleaned);
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ function formatFareFamilyBadgeLabel(value) {
113
+ return truncateDetailText(`Fare: ${value}`, 28);
114
+ }
115
+ function splitDetailText(value) {
116
+ return value.split(/(?:\r?\n|;|•|\|)+/).map((part) => part.trim()).filter((part) => part.length > 0);
117
+ }
118
+ function collectTextSources(offer) {
119
+ const sources = [];
120
+ const ancillaries = offer.ancillaries;
121
+ for (const ancillary of [ancillaries?.cabin_bag, ancillaries?.checked_bag, ancillaries?.seat_selection]) {
122
+ if (typeof ancillary?.description === "string" && ancillary.description.trim().length > 0) {
123
+ for (const fragment of splitDetailText(ancillary.description.trim())) {
124
+ sources.push({
125
+ text: fragment,
126
+ included: ancillary.included,
127
+ source: "ancillary"
128
+ });
129
+ }
130
+ }
131
+ }
132
+ for (const [key, value] of Object.entries(offer.conditions ?? {})) {
133
+ if (key === "refund_before_departure" || key === "change_before_departure") {
134
+ continue;
135
+ }
136
+ if (typeof value === "string" && value.trim().length > 0) {
137
+ for (const fragment of splitDetailText(value.trim())) {
138
+ sources.push({
139
+ text: `${key.replace(/_/g, " ")}: ${fragment}`,
140
+ source: "condition"
141
+ });
142
+ }
143
+ }
144
+ }
145
+ return sources;
146
+ }
147
+ function buildUnknownAmenityAssessment() {
148
+ return {
149
+ state: "unknown",
150
+ confidence: "unknown",
151
+ source: "unknown"
152
+ };
153
+ }
154
+ function scoreAmenityAssessment(assessment) {
155
+ const confidenceScore = assessment.confidence === "verified" ? 20 : assessment.confidence === "inferred" ? 10 : 0;
156
+ const stateScore = assessment.state === "included" ? 3 : assessment.state === "available" ? 2 : assessment.state === "unavailable" ? 1 : 0;
157
+ return confidenceScore + stateScore;
158
+ }
159
+ function chooseAmenityAssessment(current, next) {
160
+ return scoreAmenityAssessment(next) > scoreAmenityAssessment(current) ? next : current;
161
+ }
162
+ function normalizeAmenitySignal(assessment) {
163
+ if (assessment.confidence !== "verified") {
164
+ return null;
165
+ }
166
+ if (assessment.state === "included" || assessment.state === "available") {
167
+ return assessment.state;
168
+ }
169
+ return null;
170
+ }
171
+ function assessServiceSignal(pattern, sources, options) {
172
+ let best = buildUnknownAmenityAssessment();
173
+ for (const source of sources) {
174
+ if (!pattern.test(source.text)) {
175
+ continue;
176
+ }
177
+ const normalized = source.text.toLowerCase();
178
+ let candidate;
179
+ if (SERVICE_UNAVAILABLE_RE.test(normalized)) {
180
+ candidate = {
181
+ state: "unavailable",
182
+ confidence: "verified",
183
+ source: source.source,
184
+ evidence: source.text
185
+ };
186
+ } else if (SERVICE_AVAILABLE_RE.test(normalized) || source.included === false) {
187
+ candidate = {
188
+ state: "available",
189
+ confidence: "verified",
190
+ source: source.source,
191
+ evidence: source.text
192
+ };
193
+ } else if (SERVICE_INCLUDED_RE.test(normalized)) {
194
+ candidate = {
195
+ state: "included",
196
+ confidence: "verified",
197
+ source: source.source,
198
+ evidence: source.text
199
+ };
200
+ } else if (options?.barePattern?.test(normalized)) {
201
+ candidate = {
202
+ state: options.bareState ?? "included",
203
+ confidence: "verified",
204
+ source: source.source,
205
+ evidence: source.text
206
+ };
207
+ } else if (source.included === true) {
208
+ candidate = {
209
+ state: "included",
210
+ confidence: "inferred",
211
+ source: source.source,
212
+ evidence: source.text
213
+ };
214
+ } else {
215
+ candidate = {
216
+ state: "available",
217
+ confidence: "inferred",
218
+ source: source.source,
219
+ evidence: source.text
220
+ };
221
+ }
222
+ best = chooseAmenityAssessment(best, candidate);
223
+ }
224
+ return best;
225
+ }
226
+ function classifyConditionStateFromText(sources, textRe, notAllowedRe, withFeeRe, allowedRe) {
227
+ for (const source of sources) {
228
+ const normalized = source.text.toLowerCase();
229
+ if (!textRe.test(normalized)) {
230
+ continue;
231
+ }
232
+ if (notAllowedRe.test(normalized)) {
233
+ return "not_allowed";
234
+ }
235
+ if (withFeeRe.test(normalized)) {
236
+ return "allowed_with_fee";
237
+ }
238
+ if (allowedRe.test(normalized)) {
239
+ return "allowed";
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+ function extractOfferDetailSignals(offer) {
245
+ const textSources = collectTextSources(offer);
246
+ const mealAssessment = assessServiceSignal(MEAL_RE, textSources);
247
+ const refreshmentAssessment = assessServiceSignal(REFRESHMENT_RE, textSources);
248
+ const insuranceAssessment = assessServiceSignal(INSURANCE_RE, textSources);
249
+ const loungeAssessment = assessServiceSignal(LOUNGE_RE, textSources);
250
+ const wifiAssessment = assessServiceSignal(WIFI_RE, textSources, {
251
+ barePattern: WIFI_BARE_RE,
252
+ bareState: "available"
253
+ });
254
+ const powerAssessment = assessServiceSignal(POWER_RE, textSources, {
255
+ barePattern: POWER_BARE_RE,
256
+ bareState: "included"
257
+ });
258
+ const entertainmentAssessment = assessServiceSignal(ENTERTAINMENT_RE, textSources, {
259
+ barePattern: ENTERTAINMENT_BARE_RE,
260
+ bareState: "included"
261
+ });
262
+ return {
263
+ refundability: offer.conditions?.refund_before_departure ?? classifyConditionStateFromText(
264
+ textSources,
265
+ REFUND_TEXT_RE,
266
+ REFUND_NOT_ALLOWED_RE,
267
+ REFUND_WITH_FEE_RE,
268
+ REFUND_ALLOWED_RE
269
+ ),
270
+ changeability: offer.conditions?.change_before_departure ?? classifyConditionStateFromText(
271
+ textSources,
272
+ CHANGE_TEXT_RE,
273
+ CHANGE_NOT_ALLOWED_RE,
274
+ CHANGE_WITH_FEE_RE,
275
+ CHANGE_ALLOWED_RE
276
+ ),
277
+ meals: normalizeAmenitySignal(mealAssessment),
278
+ refreshments: normalizeAmenitySignal(refreshmentAssessment),
279
+ insurance: normalizeAmenitySignal(insuranceAssessment),
280
+ lounge: normalizeAmenitySignal(loungeAssessment),
281
+ wifi: normalizeAmenitySignal(wifiAssessment),
282
+ power: normalizeAmenitySignal(powerAssessment),
283
+ entertainment: normalizeAmenitySignal(entertainmentAssessment),
284
+ amenities: {
285
+ meals: mealAssessment,
286
+ refreshments: refreshmentAssessment,
287
+ insurance: insuranceAssessment,
288
+ lounge: loungeAssessment,
289
+ wifi: wifiAssessment,
290
+ power: powerAssessment,
291
+ entertainment: entertainmentAssessment
292
+ }
293
+ };
294
+ }
295
+ var AMENITY_BADGE_META = [
296
+ {
297
+ key: "meals",
298
+ included: { key: "meal_included", label: "Meal included", tone: "positive" },
299
+ available: { key: "meal_option", label: "Meal option", tone: "neutral" },
300
+ includedNote: "Meal included in the fare data",
301
+ availableNote: "Meal option shown in the fare data"
302
+ },
303
+ {
304
+ key: "refreshments",
305
+ included: { key: "refreshments_included", label: "Refreshments included", tone: "positive" },
306
+ available: { key: "refreshments_option", label: "Refreshments available", tone: "neutral" },
307
+ includedNote: "Refreshments included in the fare data",
308
+ availableNote: "Refreshments shown in the fare data"
309
+ },
310
+ {
311
+ key: "wifi",
312
+ included: { key: "wifi_included", label: "Wi-Fi included", tone: "positive" },
313
+ available: { key: "wifi_available", label: "Wi-Fi available", tone: "neutral" },
314
+ includedNote: "Wi-Fi included in the fare data",
315
+ availableNote: "Wi-Fi availability shown in the fare data"
316
+ },
317
+ {
318
+ key: "power",
319
+ included: { key: "power_included", label: "USB / power at seat", tone: "positive" },
320
+ available: { key: "power_available", label: "USB / power available", tone: "neutral" },
321
+ includedNote: "USB or power outlet shown in the fare data",
322
+ availableNote: "USB or power availability shown in the fare data"
323
+ },
324
+ {
325
+ key: "entertainment",
326
+ included: { key: "ife_included", label: "In-flight entertainment", tone: "positive" },
327
+ available: { key: "ife_available", label: "Entertainment available", tone: "neutral" },
328
+ includedNote: "In-flight entertainment shown in the fare data",
329
+ availableNote: "Entertainment availability shown in the fare data"
330
+ },
331
+ {
332
+ key: "insurance",
333
+ included: { key: "insurance_included", label: "Insurance included", tone: "positive" },
334
+ available: { key: "insurance_option", label: "Insurance option", tone: "neutral" },
335
+ includedNote: "Insurance included in the fare data",
336
+ availableNote: "Insurance option shown in the fare data"
337
+ },
338
+ {
339
+ key: "lounge",
340
+ included: { key: "lounge_included", label: "Lounge included", tone: "positive" },
341
+ available: { key: "lounge_option", label: "Lounge option", tone: "neutral" },
342
+ includedNote: "Lounge access included in the fare data",
343
+ availableNote: "Lounge access option shown in the fare data"
344
+ }
345
+ ];
346
+ function getOfferDetailBadges(offer) {
347
+ const signals = extractOfferDetailSignals(offer);
348
+ const badges = [];
349
+ const fareFamily = getConditionValue(offer, "fare_family", "fare_bundle");
350
+ if (signals.refundability === "allowed" && signals.changeability === "allowed") {
351
+ badges.push({ key: "flexible", label: "Flexible fare", tone: "positive" });
352
+ } else {
353
+ if (signals.refundability === "allowed") {
354
+ badges.push({ key: "refund_allowed", label: "Refundable", tone: "positive" });
355
+ } else if (signals.refundability === "allowed_with_fee") {
356
+ badges.push({ key: "refund_fee", label: "Refund with fee", tone: "neutral" });
357
+ } else if (signals.refundability === "not_allowed") {
358
+ badges.push({ key: "refund_none", label: "No refund", tone: "negative" });
359
+ }
360
+ if (signals.changeability === "allowed") {
361
+ badges.push({ key: "change_allowed", label: "Changes allowed", tone: "positive" });
362
+ } else if (signals.changeability === "allowed_with_fee") {
363
+ badges.push({ key: "change_fee", label: "Changes with fee", tone: "neutral" });
364
+ } else if (signals.changeability === "not_allowed") {
365
+ badges.push({ key: "change_none", label: "No changes", tone: "negative" });
366
+ }
367
+ }
368
+ if (fareFamily) {
369
+ badges.push({ key: "fare_family", label: formatFareFamilyBadgeLabel(fareFamily), tone: "neutral" });
370
+ }
371
+ for (const amenity of AMENITY_BADGE_META) {
372
+ const signal = signals[amenity.key];
373
+ if (signal === "included") {
374
+ badges.push(amenity.included);
375
+ } else if (signal === "available") {
376
+ badges.push(amenity.available);
377
+ }
378
+ }
379
+ return badges.slice(0, 4);
380
+ }
381
+ function getOfferDetailPromptNotes(offer) {
382
+ const signals = extractOfferDetailSignals(offer);
383
+ const textSources = collectTextSources(offer);
384
+ const notes = [];
385
+ const fareFamily = getConditionValue(offer, "fare_family", "fare_bundle");
386
+ const fareBenefits = summarizeConditionList(getConditionValue(offer, "fare_bundle_benefits", "fare_bundle_description"));
387
+ if (fareFamily) {
388
+ notes.push(`Fare family shown: ${fareFamily}`);
389
+ }
390
+ if (fareBenefits) {
391
+ notes.push(`Fare bundle benefits shown: ${fareBenefits}`);
392
+ }
393
+ if (signals.refundability === "allowed") {
394
+ notes.push("Refunds allowed before departure");
395
+ } else if (signals.refundability === "allowed_with_fee") {
396
+ notes.push("Refunds allowed before departure with a fee");
397
+ } else if (signals.refundability === "not_allowed") {
398
+ notes.push("No refunds shown before departure");
399
+ }
400
+ if (signals.changeability === "allowed") {
401
+ notes.push("Changes allowed before departure");
402
+ } else if (signals.changeability === "allowed_with_fee") {
403
+ notes.push("Changes allowed before departure with a fee");
404
+ } else if (signals.changeability === "not_allowed") {
405
+ notes.push("No changes shown before departure");
406
+ }
407
+ for (const amenity of AMENITY_BADGE_META) {
408
+ const signal = signals[amenity.key];
409
+ if (signal === "included") {
410
+ notes.push(amenity.includedNote);
411
+ } else if (signal === "available") {
412
+ notes.push(amenity.availableNote);
413
+ }
414
+ }
415
+ const seatSelectionEvidence = findMatchingSourceText(textSources, SEAT_SELECTION_RE);
416
+ if (seatSelectionEvidence && !(fareBenefits && fareBenefits.toLowerCase().includes(seatSelectionEvidence.toLowerCase()))) {
417
+ pushUniqueNote(notes, `Seat selection shown in fare data: ${seatSelectionEvidence}`);
418
+ }
419
+ const legroomEvidence = findMatchingSourceText(textSources, LEGROOM_RE);
420
+ if (legroomEvidence) {
421
+ pushUniqueNote(notes, `Legroom shown in fare data: ${legroomEvidence}`);
422
+ }
423
+ const flightNumbers = collectFlightNumbers(offer);
424
+ if (flightNumbers.length > 0) {
425
+ pushUniqueNote(notes, `Flight numbers shown: ${flightNumbers.join(", ")}`);
426
+ }
427
+ const aircraftTypes = collectAircraftTypes(offer);
428
+ if (aircraftTypes.length > 0) {
429
+ pushUniqueNote(notes, `Aircraft shown: ${aircraftTypes.join(", ")}`);
430
+ }
431
+ const operatingCarriers = collectOperatingCarriers(offer);
432
+ const normalizedOfferAirline = typeof offer.airline === "string" ? offer.airline.trim().toLowerCase() : "";
433
+ if (operatingCarriers.length === 1 && operatingCarriers[0].toLowerCase() !== normalizedOfferAirline) {
434
+ pushUniqueNote(notes, `Operating carrier shown: ${operatingCarriers[0]}`);
435
+ } else if (operatingCarriers.length > 1) {
436
+ pushUniqueNote(notes, `Operating carriers shown: ${operatingCarriers.join(", ")}`);
437
+ }
438
+ return notes;
439
+ }
440
+
441
+ // src/trip-purpose.ts
442
+ var TRIP_PURPOSES = [
443
+ "honeymoon",
444
+ "special_occasion",
445
+ "business",
446
+ "ski",
447
+ "beach",
448
+ "city_break",
449
+ "family_holiday",
450
+ "graduation",
451
+ "concert_festival",
452
+ "sports_event",
453
+ "spring_break"
454
+ ];
455
+ function normalizeTripPurposes({ tripPurpose, tripPurposes }) {
456
+ const normalized = [];
457
+ const seen = /* @__PURE__ */ new Set();
458
+ for (const purpose of tripPurposes ?? []) {
459
+ if (!purpose || seen.has(purpose)) continue;
460
+ seen.add(purpose);
461
+ normalized.push(purpose);
462
+ }
463
+ if (tripPurpose && !seen.has(tripPurpose)) {
464
+ normalized.push(tripPurpose);
465
+ }
466
+ return normalized;
467
+ }
468
+ function getPrimaryTripPurpose(options) {
469
+ return normalizeTripPurposes(options)[0];
470
+ }
471
+
472
+ // src/ranking.ts
473
+ var GF_SAVINGS_COMPARISON_ENABLED = false;
474
+ function normalizeGoogleFlightsComparisonPrice(googleFlightsPrice, _travelerCount = 1) {
475
+ if (!Number.isFinite(googleFlightsPrice)) {
476
+ return null;
477
+ }
478
+ const normalized = Math.round(googleFlightsPrice * 100) / 100;
479
+ return normalized > 0 ? normalized : null;
480
+ }
481
+ function getOfferInstanceKey(offer) {
482
+ return [
483
+ offer.id,
484
+ offer.departure_time,
485
+ offer.arrival_time,
486
+ offer.stops,
487
+ offer.inbound?.departure_time ?? "",
488
+ offer.inbound?.arrival_time ?? "",
489
+ offer.inbound?.stops ?? ""
490
+ ].join("|");
491
+ }
492
+ var W = {
493
+ // Generic solo / default — price-driven
494
+ default: {
495
+ price: 0.34,
496
+ stops: 0.22,
497
+ duration: 0.12,
498
+ depTime: 0.08,
499
+ arrivalTime: 0.04,
500
+ baggage: 0.02,
501
+ savings: 0.06,
502
+ comfortHours: 0.04,
503
+ layover: 0.08
504
+ },
505
+ // Business — time and directness > price; long layovers are unacceptable
506
+ business_traveler: {
507
+ price: 0.1,
508
+ stops: 0.26,
509
+ duration: 0.2,
510
+ depTime: 0.18,
511
+ arrivalTime: 0.04,
512
+ baggage: 0.06,
513
+ savings: 0,
514
+ comfortHours: 0.06,
515
+ layover: 0.1
516
+ },
517
+ // Family — directness + baggage practicality; kids + 8h layover = nightmare
518
+ family: {
519
+ price: 0.12,
520
+ stops: 0.2,
521
+ duration: 0.16,
522
+ depTime: 0.08,
523
+ arrivalTime: 0.04,
524
+ baggage: 0.2,
525
+ savings: 0.04,
526
+ comfortHours: 0.08,
527
+ layover: 0.08
528
+ },
529
+ // Couple — balance of price, comfort, arrival time, savings
530
+ couple: {
531
+ price: 0.22,
532
+ stops: 0.2,
533
+ duration: 0.12,
534
+ depTime: 0.1,
535
+ arrivalTime: 0.14,
536
+ baggage: 0.02,
537
+ savings: 0.1,
538
+ comfortHours: 0.04,
539
+ layover: 0.06
540
+ },
541
+ // Honeymoon — direct > everything; no one wants a 10h layover on their honeymoon
542
+ honeymoon: {
543
+ price: 0.08,
544
+ stops: 0.28,
545
+ duration: 0.1,
546
+ depTime: 0.14,
547
+ arrivalTime: 0.18,
548
+ baggage: 0.02,
549
+ savings: 0.02,
550
+ comfortHours: 0.08,
551
+ layover: 0.1
552
+ },
553
+ // Special occasion — still prioritize smooth timing/directness, but keep price meaningful.
554
+ special_occasion: {
555
+ price: 0.14,
556
+ stops: 0.24,
557
+ duration: 0.1,
558
+ depTime: 0.12,
559
+ arrivalTime: 0.16,
560
+ baggage: 0.02,
561
+ savings: 0.06,
562
+ comfortHours: 0.08,
563
+ layover: 0.08
564
+ },
565
+ // Ski — bag essential (equipment); early arrival to maximize slopes
566
+ ski: {
567
+ price: 0.12,
568
+ stops: 0.16,
569
+ duration: 0.1,
570
+ depTime: 0.16,
571
+ arrivalTime: 0.08,
572
+ baggage: 0.24,
573
+ savings: 0.04,
574
+ comfortHours: 0.02,
575
+ layover: 0.08
576
+ },
577
+ // Beach — arrive early to enjoy the day; price matters
578
+ beach: {
579
+ price: 0.24,
580
+ stops: 0.16,
581
+ duration: 0.1,
582
+ depTime: 0.1,
583
+ arrivalTime: 0.14,
584
+ baggage: 0.1,
585
+ savings: 0.06,
586
+ comfortHours: 0.04,
587
+ layover: 0.06
588
+ },
589
+ // City break — maximize time on the ground; 2-day trip loses half a day to a 6h layover
590
+ city_break: {
591
+ price: 0.26,
592
+ stops: 0.2,
593
+ duration: 0.08,
594
+ depTime: 0.12,
595
+ arrivalTime: 0.16,
596
+ baggage: 0.02,
597
+ savings: 0.04,
598
+ comfortHours: 0.04,
599
+ layover: 0.08
600
+ },
601
+ // Quick flight — user explicitly wants shortest possible total duration
602
+ quick_flight: {
603
+ price: 0.14,
604
+ stops: 0.2,
605
+ duration: 0.4,
606
+ depTime: 0.06,
607
+ arrivalTime: 0.04,
608
+ baggage: 0.02,
609
+ savings: 0.04,
610
+ comfortHours: 0.04,
611
+ layover: 0.06
612
+ },
613
+ // Cheapest — user explicitly asked for lowest price; price overwhelms all other factors
614
+ cheapest: {
615
+ price: 0.88,
616
+ stops: 0.06,
617
+ duration: 0.03,
618
+ depTime: 0.01,
619
+ arrivalTime: 0.01,
620
+ baggage: 0.01,
621
+ savings: 0,
622
+ comfortHours: 0,
623
+ layover: 0
624
+ },
625
+ // Cheapest direct — user asked for BOTH cheapest AND direct/nonstop.
626
+ // Stops must dominate enough that a direct flight wins even if it costs more;
627
+ // price wins within the same stop tier (all directs sorted by price, all 1-stops sorted
628
+ // by price below them, etc.). The scoreStops delta between 0-stop (1.00) and 1-stop (0.40)
629
+ // is 0.60; with stops weight 0.38 that gap is worth 0.228, which means a 1-stop would
630
+ // need an enormous price advantage to beat a direct — effectively "direct first".
631
+ cheapest_direct: {
632
+ price: 0.52,
633
+ stops: 0.38,
634
+ duration: 0.04,
635
+ depTime: 0.02,
636
+ arrivalTime: 0.01,
637
+ baggage: 0.01,
638
+ savings: 0,
639
+ comfortHours: 0,
640
+ layover: 0.02
641
+ }
642
+ };
643
+ var PURPOSE_WEIGHT_PROFILES = {
644
+ honeymoon: W.honeymoon,
645
+ special_occasion: W.special_occasion,
646
+ business: W.business_traveler,
647
+ ski: W.ski,
648
+ beach: W.beach,
649
+ city_break: W.city_break,
650
+ family_holiday: W.family,
651
+ graduation: W.city_break,
652
+ concert_festival: W.city_break,
653
+ sports_event: W.city_break,
654
+ spring_break: W.beach
655
+ };
656
+ var EARLY_ARRIVAL_PURPOSES = /* @__PURE__ */ new Set([
657
+ "city_break",
658
+ "beach",
659
+ "special_occasion",
660
+ "graduation",
661
+ "concert_festival",
662
+ "sports_event",
663
+ "spring_break"
664
+ ]);
665
+ function blendWeights(profiles) {
666
+ if (profiles.length === 0) return { ...W.default };
667
+ const blended = {
668
+ price: 0,
669
+ stops: 0,
670
+ duration: 0,
671
+ depTime: 0,
672
+ arrivalTime: 0,
673
+ baggage: 0,
674
+ savings: 0,
675
+ comfortHours: 0,
676
+ layover: 0
677
+ };
678
+ for (const profile of profiles) {
679
+ blended.price += profile.price;
680
+ blended.stops += profile.stops;
681
+ blended.duration += profile.duration;
682
+ blended.depTime += profile.depTime;
683
+ blended.arrivalTime += profile.arrivalTime;
684
+ blended.baggage += profile.baggage;
685
+ blended.savings += profile.savings;
686
+ blended.comfortHours += profile.comfortHours;
687
+ blended.layover += profile.layover;
688
+ }
689
+ return {
690
+ price: blended.price / profiles.length,
691
+ stops: blended.stops / profiles.length,
692
+ duration: blended.duration / profiles.length,
693
+ depTime: blended.depTime / profiles.length,
694
+ arrivalTime: blended.arrivalTime / profiles.length,
695
+ baggage: blended.baggage / profiles.length,
696
+ savings: blended.savings / profiles.length,
697
+ comfortHours: blended.comfortHours / profiles.length,
698
+ layover: blended.layover / profiles.length
699
+ };
700
+ }
701
+ function resolveWeights(ctx) {
702
+ if (ctx.preferCheapest && ctx.preferDirect) return { ...W.cheapest_direct };
703
+ if (ctx.preferCheapest) return { ...W.cheapest };
704
+ const tripPurposes = normalizeTripPurposes({
705
+ tripPurpose: ctx.tripPurpose,
706
+ tripPurposes: ctx.tripPurposes
707
+ });
708
+ const purposeProfiles = tripPurposes.map((purpose) => PURPOSE_WEIGHT_PROFILES[purpose]);
709
+ const w = ctx.preferQuickFlight ? { ...W.quick_flight } : purposeProfiles.length > 0 ? blendWeights(purposeProfiles) : ctx.tripContext === "business_traveler" ? { ...W.business_traveler } : ctx.tripContext === "family" ? { ...W.family } : ctx.tripContext === "couple" ? { ...W.couple } : { ...W.default };
710
+ if (ctx.preferDirect) {
711
+ const boost = 0.2;
712
+ w.stops = Math.min(0.5, w.stops + boost);
713
+ const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
714
+ const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
715
+ w.price -= fromPrice;
716
+ w.duration -= fromDuration;
717
+ }
718
+ if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
719
+ const boost = 0.12;
720
+ w.stops = Math.min(0.44, w.stops + boost);
721
+ const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
722
+ const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
723
+ w.price -= fromPrice;
724
+ w.duration -= fromDuration;
725
+ }
726
+ if (ctx.preferLongLayover) {
727
+ const boost = 0.12;
728
+ w.layover = Math.min(0.24, w.layover + boost);
729
+ const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
730
+ const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
731
+ w.price -= fromPrice;
732
+ w.duration -= fromDuration;
733
+ }
734
+ if (ctx.depTimePref) {
735
+ const boost = 0.13;
736
+ w.depTime = Math.min(0.36, w.depTime + boost);
737
+ const fromArrival = Math.min(boost * 0.65, Math.max(0, w.arrivalTime - 0.03));
738
+ const fromDuration = Math.min(boost - fromArrival, Math.max(0, w.duration - 0.03));
739
+ w.arrivalTime -= fromArrival;
740
+ w.duration -= fromDuration;
741
+ }
742
+ if (ctx.requireBag && w.baggage < 0.15) {
743
+ const boost = 0.12;
744
+ w.baggage += boost;
745
+ w.price = Math.max(0.04, w.price - boost * 0.6);
746
+ w.duration = Math.max(0.02, w.duration - boost * 0.4);
747
+ }
748
+ return w;
749
+ }
750
+ function timeConstraintPenalty(depMins, afterMins, beforeMins) {
751
+ if (afterMins !== void 0 && depMins < afterMins) return 0.05;
752
+ if (beforeMins !== void 0 && depMins > beforeMins) return 0.05;
753
+ return 1;
754
+ }
755
+ function depTimeMismatchMultiplier(depMins, depTimePref) {
756
+ if (!depTimePref) return 1;
757
+ const s = scoreDepTime(depMins, depTimePref);
758
+ if (s >= 0.55) return 1;
759
+ if (s >= 0.3) return 0.8;
760
+ return 0.55;
761
+ }
762
+ function retDepTimeMismatchMultiplier(offer, retTimePref) {
763
+ if (!retTimePref) return 1;
764
+ const retDep = offer.inbound?.departure_time;
765
+ if (!retDep) return 1;
766
+ const retDepMins = isoToMins(retDep);
767
+ const s = scoreDepTime(retDepMins, retTimePref);
768
+ if (s >= 0.55) return 1;
769
+ if (s >= 0.15) return 0.88;
770
+ return 0.7;
771
+ }
772
+ function deduplicateOffers(offers) {
773
+ const mergeAncillary = (current, next) => {
774
+ if (!current) return next ? { ...next } : void 0;
775
+ if (!next) return current;
776
+ return {
777
+ included: typeof current.included === "boolean" ? current.included : next.included,
778
+ price: typeof current.price === "number" ? current.price : next.price,
779
+ currency: current.currency || next.currency,
780
+ description: current.description || next.description
781
+ };
782
+ };
783
+ const mergeOfferDetails = (representative, bucketOffers, representativePrice) => {
784
+ const samePriceMatches = bucketOffers.filter((candidate) => Math.abs((candidate.displayPrice ?? candidate.price) - representativePrice) < 0.01);
785
+ if (samePriceMatches.length <= 1) {
786
+ return representative;
787
+ }
788
+ let mergedAncillaries = representative.ancillaries ? {
789
+ cabin_bag: representative.ancillaries.cabin_bag,
790
+ checked_bag: representative.ancillaries.checked_bag,
791
+ seat_selection: representative.ancillaries.seat_selection
792
+ } : void 0;
793
+ let mergedConditions = representative.conditions ? { ...representative.conditions } : void 0;
794
+ for (const candidate of samePriceMatches) {
795
+ if (candidate.ancillaries) {
796
+ mergedAncillaries = {
797
+ cabin_bag: mergeAncillary(mergedAncillaries?.cabin_bag, candidate.ancillaries.cabin_bag),
798
+ checked_bag: mergeAncillary(mergedAncillaries?.checked_bag, candidate.ancillaries.checked_bag),
799
+ seat_selection: mergeAncillary(mergedAncillaries?.seat_selection, candidate.ancillaries.seat_selection)
800
+ };
801
+ }
802
+ if (candidate.conditions) {
803
+ const nextConditions = { ...mergedConditions ?? {} };
804
+ for (const [conditionKey, conditionValue] of Object.entries(candidate.conditions)) {
805
+ if (!conditionValue) continue;
806
+ const currentValue = nextConditions[conditionKey];
807
+ if (!currentValue || currentValue === "unknown") {
808
+ nextConditions[conditionKey] = conditionValue;
809
+ }
810
+ }
811
+ mergedConditions = nextConditions;
812
+ }
813
+ }
814
+ return {
815
+ ...representative,
816
+ ancillaries: mergedAncillaries,
817
+ conditions: mergedConditions
818
+ };
819
+ };
820
+ const bucket = (iso) => Math.round(isoToMins(iso) / 30);
821
+ const dayKey = (iso) => {
822
+ if (!iso) return "";
823
+ const d = new Date(iso);
824
+ return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
825
+ };
826
+ const legKey = (departureTime, arrivalTime, origin, destination, stops, segments) => {
827
+ const intermediate = segments && segments.length > 1 ? segments.slice(0, -1).map((s) => (s.destination ?? "").toUpperCase()).filter(Boolean).sort().join(",") : "";
828
+ return [
829
+ dayKey(departureTime),
830
+ (origin ?? "").toUpperCase(),
831
+ (destination ?? "").toUpperCase(),
832
+ departureTime ? bucket(departureTime) : "",
833
+ arrivalTime ? bucket(arrivalTime) : "",
834
+ stops ?? "",
835
+ intermediate
836
+ ].join("_");
837
+ };
838
+ const normRefund = (c) => c === "allowed" || c === "allowed_with_fee" ? c : "nonrefundable";
839
+ const key = (o) => [
840
+ (o.airline ?? "").toUpperCase(),
841
+ legKey(o.departure_time, o.arrival_time, o.origin, o.destination, o.stops, o.segments),
842
+ o.inbound ? legKey(
843
+ o.inbound.departure_time,
844
+ o.inbound.arrival_time,
845
+ o.inbound.origin,
846
+ o.inbound.destination,
847
+ o.inbound.stops,
848
+ o.inbound.segments
849
+ ) : "oneway",
850
+ normRefund(o.conditions?.refund_before_departure)
851
+ ].join("_");
852
+ const buckets = /* @__PURE__ */ new Map();
853
+ offers.forEach((offer, index) => {
854
+ const offerKey = key(offer);
855
+ const effectivePrice = offer.displayPrice ?? offer.price;
856
+ const bucketState = buckets.get(offerKey);
857
+ if (!bucketState) {
858
+ buckets.set(offerKey, {
859
+ minPrice: effectivePrice,
860
+ representative: offer,
861
+ representativeIndex: index,
862
+ offers: [offer]
863
+ });
864
+ return;
865
+ }
866
+ bucketState.offers.push(offer);
867
+ if (effectivePrice < bucketState.minPrice - 0.01) {
868
+ bucketState.minPrice = effectivePrice;
869
+ bucketState.representative = offer;
870
+ bucketState.representativeIndex = index;
871
+ }
872
+ });
873
+ return [...buckets.values()].sort((left, right) => left.representativeIndex - right.representativeIndex).map((bucketState) => mergeOfferDetails(bucketState.representative, bucketState.offers, bucketState.minPrice));
874
+ }
875
+ function isoToMins(iso) {
876
+ if (!iso) return 0;
877
+ const match = /T(\d{2}):(\d{2})/.exec(iso);
878
+ if (match) return parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
879
+ const d = new Date(iso);
880
+ if (isNaN(d.getTime())) return 0;
881
+ return d.getUTCHours() * 60 + d.getUTCMinutes();
882
+ }
883
+ function daysBetween(depIso, arrIso) {
884
+ if (!depIso || !arrIso) return 0;
885
+ const dep = new Date(depIso);
886
+ const arr = new Date(arrIso);
887
+ if (isNaN(dep.getTime()) || isNaN(arr.getTime())) return 0;
888
+ const depDay = Math.floor(dep.getTime() / 864e5);
889
+ const arrDay = Math.floor(arr.getTime() / 864e5);
890
+ return Math.max(0, arrDay - depDay);
891
+ }
892
+ function formatMins(mins) {
893
+ const h = Math.floor(mins / 60) % 24;
894
+ const m = mins % 60;
895
+ const ampm = h >= 12 ? "pm" : "am";
896
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
897
+ return `${h12}:${m.toString().padStart(2, "0")}${ampm}`;
898
+ }
899
+ function p5p95(arr) {
900
+ if (arr.length === 0) return [0, 0];
901
+ const s = [...arr].sort((a, b) => a - b);
902
+ const lo = s[Math.floor(s.length * 0.05)] ?? s[0];
903
+ const hi = s[Math.min(s.length - 1, Math.ceil(s.length * 0.95) - 1)];
904
+ return [lo, hi];
905
+ }
906
+ function scorePrice(price, lo, hi) {
907
+ if (hi <= lo) return 1;
908
+ return 1 - Math.max(0, Math.min(1, (price - lo) / (hi - lo)));
909
+ }
910
+ function scoreDuration(mins, lo, hi) {
911
+ if (hi <= lo) return 0.8;
912
+ return 1 - Math.max(0, Math.min(1, (mins - lo) / (hi - lo)));
913
+ }
914
+ function scoreStops(stops) {
915
+ if (stops === 0) return 1;
916
+ if (stops === 1) return 0.4;
917
+ if (stops === 2) return 0.14;
918
+ return 0.04;
919
+ }
920
+ var TIME_RANGES = {
921
+ // [perfectLo, perfectHi, okLo, okHi] — all in minutes from midnight
922
+ early_morning: [0, 330, 330, 420],
923
+ morning: [360, 660, 300, 750],
924
+ afternoon: [720, 1020, 660, 1140],
925
+ evening: [1080, 1320, 1020, 1380],
926
+ // "night" is later/stricter than "evening" — when a user says "Sunday night
927
+ // back" they mean ~21:00+, not 18:00. Perfect 21:00–23:30, ok 20:00–23:59.
928
+ // A 18:20 return fails this gate while still passing the evening gate.
929
+ night: [1260, 1410, 1200, 1439],
930
+ red_eye: [1320, 1439, 0, 420]
931
+ };
932
+ function scoreDepTime(depMins, pref) {
933
+ if (!pref) {
934
+ if (depMins >= 360 && depMins <= 1260) return 0.8;
935
+ if (depMins >= 270 && depMins <= 1380) return 0.5;
936
+ return 0.2;
937
+ }
938
+ const r = TIME_RANGES[pref];
939
+ if (!r) return 0.5;
940
+ const [pLo, pHi, oLo, oHi] = r;
941
+ if (depMins >= pLo && depMins <= pHi) return 1;
942
+ if (depMins >= oLo && depMins <= oHi) return 0.6;
943
+ const distBelowOk = Math.max(0, oLo - depMins);
944
+ const distAboveOk = Math.max(0, depMins - oHi);
945
+ const dist = distBelowOk > 0 ? distBelowOk : distAboveOk;
946
+ const span = oHi - oLo;
947
+ return Math.max(0.05, 0.2 * Math.exp(-dist / Math.max(span * 1.5, 60)));
948
+ }
949
+ function scoreArrivalTime(arrMins, pref, tripPurposes, dayOffset = 0) {
950
+ const isExploring = (tripPurposes ?? []).some((purpose) => EARLY_ARRIVAL_PURPOSES.has(purpose));
951
+ if (!pref && !isExploring) return 0.5;
952
+ let base;
953
+ if (pref === "morning") {
954
+ if (arrMins < 720) base = 1;
955
+ else if (arrMins < 900) base = 0.65;
956
+ else base = 0.25;
957
+ } else if (pref === "afternoon") {
958
+ if (arrMins >= 720 && arrMins < 1020) base = 1;
959
+ else if (arrMins < 1140) base = 0.65;
960
+ else base = 0.25;
961
+ } else if (pref === "evening") {
962
+ if (arrMins >= 1020 && arrMins < 1320) base = 1;
963
+ else if (arrMins >= 900) base = 0.65;
964
+ else base = 0.25;
965
+ } else {
966
+ if (arrMins < 360) {
967
+ base = dayOffset >= 1 ? 0.15 : 0.3;
968
+ } else if (arrMins < 600) base = 1;
969
+ else if (arrMins < 720) base = 0.85;
970
+ else if (arrMins < 900) base = 0.7;
971
+ else if (arrMins < 1080) base = 0.55;
972
+ else if (arrMins < 1260) base = 0.35;
973
+ else base = 0.15;
974
+ }
975
+ if (dayOffset >= 2) {
976
+ const extraDays = dayOffset - 1;
977
+ const penalty = isExploring ? Math.min(base, extraDays * 0.45) : Math.min(base, extraDays * 0.25);
978
+ base = Math.max(0, base - penalty);
979
+ }
980
+ return base;
981
+ }
982
+ function scoreBaggage(offer, requireBag) {
983
+ const bag = offer.ancillaries?.checked_bag;
984
+ const isIncluded = bag?.included === true;
985
+ const fee = bag?.included === false ? bag?.price ?? null : null;
986
+ if (!requireBag) {
987
+ return isIncluded ? 0.8 : 0.5;
988
+ }
989
+ if (isIncluded) return 1;
990
+ if (fee === null) return 0.3;
991
+ const ratio = fee / Math.max(offer.price, 1);
992
+ if (ratio < 0.05) return 0.72;
993
+ if (ratio < 0.12) return 0.52;
994
+ if (ratio < 0.22) return 0.32;
995
+ return 0.12;
996
+ }
997
+ function scoreSavings(offer, travelerCount) {
998
+ const gfp = normalizeGoogleFlightsComparisonPrice(offer.google_flights_price, travelerCount);
999
+ if (!gfp || gfp <= 0) return 0.5;
1000
+ const pct = (gfp - offer.price) / gfp;
1001
+ if (pct >= 0.2) return 1;
1002
+ if (pct >= 0.12) return 0.85;
1003
+ if (pct >= 0.06) return 0.72;
1004
+ if (pct >= 0.01) return 0.6;
1005
+ if (pct >= -0.05) return 0.48;
1006
+ return 0.22;
1007
+ }
1008
+ function scoreComfortHours(depMins) {
1009
+ if (depMins >= 360 && depMins <= 1320) return 1;
1010
+ if (depMins >= 270 && depMins <= 1380) return 0.6;
1011
+ if (depMins >= 180) return 0.3;
1012
+ return 0.1;
1013
+ }
1014
+ function scoreLayover(offer, preferLong = false) {
1015
+ const outStops = offer.stops ?? 0;
1016
+ const inStops = offer.inbound?.stops ?? 0;
1017
+ if (outStops === 0 && inStops === 0) return preferLong ? 0.7 : 1;
1018
+ const layoverMins = [
1019
+ ...offer.segments ?? [],
1020
+ ...offer.inbound?.segments ?? []
1021
+ ].filter((s) => (s.layover_minutes ?? 0) > 0).map((s) => s.layover_minutes);
1022
+ if (layoverMins.length === 0) return 0.5;
1023
+ const worst = Math.max(...layoverMins);
1024
+ if (preferLong) {
1025
+ if (worst < 40) return 0.1;
1026
+ if (worst < 120) return 0.3;
1027
+ if (worst < 240) return 0.55;
1028
+ if (worst < 480) return 0.78;
1029
+ if (worst <= 900) return 1;
1030
+ if (worst <= 1440) return 0.92;
1031
+ return 0.75;
1032
+ }
1033
+ if (worst < 40) return 0.1;
1034
+ if (worst < 60) return 0.35;
1035
+ if (worst <= 180) return 1;
1036
+ if (worst <= 240) return 0.82;
1037
+ if (worst <= 360) return 0.55;
1038
+ if (worst <= 480) return 0.28;
1039
+ if (worst <= 660) return 0.12;
1040
+ return 0.04;
1041
+ }
1042
+ function combinedStops(offer) {
1043
+ const out = offer.stops ?? 0;
1044
+ const inb = offer.inbound?.stops ?? 0;
1045
+ return Math.max(out, inb);
1046
+ }
1047
+ function totalRoundTripDuration(offer) {
1048
+ const out = offer.duration_minutes ?? 0;
1049
+ if (!offer.inbound) return out;
1050
+ const inb = offer.inbound.duration_minutes ?? 0;
1051
+ return out + inb;
1052
+ }
1053
+ function refundabilityMultiplier(offer, requireCancellation) {
1054
+ if (!requireCancellation) return 1;
1055
+ const refundability = offer.conditions?.refund_before_departure;
1056
+ if (refundability === "allowed") return 1;
1057
+ if (refundability === "allowed_with_fee") return 0.82;
1058
+ if (refundability === "unknown" || refundability === void 0) return 0.58;
1059
+ return 0.08;
1060
+ }
1061
+ function mealPreferenceMultiplier(offer, requireMeals) {
1062
+ if (!requireMeals) return 1;
1063
+ const mealSignal = extractOfferDetailSignals(offer).meals;
1064
+ if (mealSignal === "included") return 1;
1065
+ if (mealSignal === "available") return 0.84;
1066
+ return 0.56;
1067
+ }
1068
+ function generateFacts(offer, bd, refPrice, fastestMins, ctx, travelerCount, isHero) {
1069
+ const heroFacts = [];
1070
+ const tradeoffs = [];
1071
+ const cur = offer.currency;
1072
+ const priceDiff = Math.round(offer.price - refPrice);
1073
+ if (isHero) {
1074
+ if (priceDiff <= 5) {
1075
+ heroFacts.push(`cheapest available (${Math.round(offer.price)} ${cur})`);
1076
+ } else {
1077
+ tradeoffs.push(`${priceDiff} ${cur} above the cheapest option`);
1078
+ }
1079
+ } else {
1080
+ if (priceDiff < -5) {
1081
+ heroFacts.push(`${Math.abs(priceDiff)} ${cur} cheaper than the top pick`);
1082
+ } else if (priceDiff > 5) {
1083
+ tradeoffs.push(`${priceDiff} ${cur} more expensive than the top pick`);
1084
+ }
1085
+ }
1086
+ if (offer.stops === 0) {
1087
+ heroFacts.push("direct flight \u2014 no layovers or connections");
1088
+ } else if (offer.stops === 1) {
1089
+ tradeoffs.push("1 stop");
1090
+ } else {
1091
+ tradeoffs.push(`${offer.stops} stops`);
1092
+ }
1093
+ const normalizedGooglePrice = normalizeGoogleFlightsComparisonPrice(offer.google_flights_price, travelerCount);
1094
+ if (GF_SAVINGS_COMPARISON_ENABLED && normalizedGooglePrice && normalizedGooglePrice > offer.price + 8) {
1095
+ const saving = Math.round(normalizedGooglePrice - offer.price);
1096
+ heroFacts.push(
1097
+ `${saving} ${cur} cheaper than Google Flights (Google shows ${Math.round(normalizedGooglePrice)} ${cur})`
1098
+ );
1099
+ } else if (GF_SAVINGS_COMPARISON_ENABLED && normalizedGooglePrice && offer.price > normalizedGooglePrice + 8) {
1100
+ const extra = Math.round(offer.price - normalizedGooglePrice);
1101
+ tradeoffs.push(`${extra} ${cur} more expensive than Google Flights shows`);
1102
+ }
1103
+ const durDiff = offer.duration_minutes - fastestMins;
1104
+ if (durDiff === 0) {
1105
+ heroFacts.push(
1106
+ `fastest flight on this route (${Math.floor(offer.duration_minutes / 60)}h ${offer.duration_minutes % 60}m)`
1107
+ );
1108
+ } else if (durDiff > 90) {
1109
+ tradeoffs.push(
1110
+ `${Math.floor(durDiff / 60)}h ${durDiff % 60}m longer than the fastest option`
1111
+ );
1112
+ }
1113
+ const depMins = isoToMins(offer.departure_time);
1114
+ if (bd.depTime >= 0.88 && ctx.depTimePref) {
1115
+ heroFacts.push(
1116
+ `departs ${formatMins(depMins)} \u2014 matches your ${ctx.depTimePref.replace("_", " ")} preference`
1117
+ );
1118
+ } else if (bd.depTime <= 0.25 && ctx.depTimePref) {
1119
+ tradeoffs.push(
1120
+ `departure at ${formatMins(depMins)} doesn't match your ${ctx.depTimePref.replace("_", " ")} preference`
1121
+ );
1122
+ }
1123
+ const arrMins = isoToMins(offer.arrival_time);
1124
+ const tripPurposes = normalizeTripPurposes({
1125
+ tripPurpose: ctx.tripPurpose,
1126
+ tripPurposes: ctx.tripPurposes
1127
+ });
1128
+ const isExploring = tripPurposes.some((purpose) => EARLY_ARRIVAL_PURPOSES.has(purpose));
1129
+ if (bd.arrivalTime >= 0.88) {
1130
+ heroFacts.push(
1131
+ `arrives ${formatMins(arrMins)}${isExploring ? " \u2014 full day to explore" : ""}`
1132
+ );
1133
+ } else if (bd.arrivalTime <= 0.25) {
1134
+ tradeoffs.push(`late arrival (${formatMins(arrMins)})`);
1135
+ }
1136
+ const bagIncluded = offer.ancillaries?.checked_bag?.included === true;
1137
+ const bagFee = offer.ancillaries?.checked_bag?.included === false ? offer.ancillaries.checked_bag.price : null;
1138
+ if (ctx.requireBag && bagIncluded) {
1139
+ heroFacts.push("checked bag already included in the ticket price");
1140
+ } else if (ctx.requireBag && bagFee != null) {
1141
+ tradeoffs.push(
1142
+ `bag costs extra (${Math.round(bagFee)} ${offer.ancillaries?.checked_bag?.currency ?? cur})`
1143
+ );
1144
+ } else if (ctx.requireBag && bagFee === null && !bagIncluded) {
1145
+ tradeoffs.push("bag fee unknown \u2014 check at booking");
1146
+ }
1147
+ const refundability = offer.conditions?.refund_before_departure;
1148
+ if (ctx.requireCancellation && refundability === "allowed") {
1149
+ heroFacts.push("refundable before departure");
1150
+ } else if (ctx.requireCancellation && refundability === "allowed_with_fee") {
1151
+ tradeoffs.push("refunds allowed with a fee");
1152
+ } else if (ctx.requireCancellation && refundability === "not_allowed") {
1153
+ tradeoffs.push("not refundable before departure");
1154
+ } else if (ctx.requireCancellation) {
1155
+ tradeoffs.push("refund policy not shown in the fare data");
1156
+ }
1157
+ const mealSignal = extractOfferDetailSignals(offer).meals;
1158
+ if (ctx.requireMeals && mealSignal === "included") {
1159
+ heroFacts.push("meal included in fare");
1160
+ } else if (ctx.requireMeals && mealSignal === "available") {
1161
+ heroFacts.push("meal option shown in fare data");
1162
+ } else if (ctx.requireMeals) {
1163
+ tradeoffs.push("meal availability not shown in the fare data");
1164
+ }
1165
+ if (ctx.preferredAirline) {
1166
+ const airLower = offer.airline.toLowerCase();
1167
+ const prefLower = ctx.preferredAirline.toLowerCase();
1168
+ if (airLower.includes(prefLower) || prefLower.includes(airLower.split(" ")[0])) {
1169
+ heroFacts.push(`with ${offer.airline} as you mentioned`);
1170
+ } else {
1171
+ tradeoffs.push(`not ${ctx.preferredAirline} (which you mentioned)`);
1172
+ }
1173
+ }
1174
+ return { heroFacts, tradeoffs };
1175
+ }
1176
+ function premiumPenalty(offerPrice, cheapestPrice) {
1177
+ if (cheapestPrice <= 0) return 1;
1178
+ const ratio = (offerPrice - cheapestPrice) / cheapestPrice;
1179
+ if (ratio <= 0) return 1;
1180
+ if (ratio <= 0.08) return 1;
1181
+ if (ratio <= 0.18) return 0.96;
1182
+ if (ratio <= 0.3) return 0.88;
1183
+ if (ratio <= 0.5) return 0.76;
1184
+ if (ratio <= 0.8) return 0.58;
1185
+ if (ratio <= 1.2) return 0.4;
1186
+ if (ratio <= 2) return 0.26;
1187
+ return 0.14;
1188
+ }
1189
+ function selectHeroByGates(sorted, ctx) {
1190
+ if (sorted.length === 0) throw new Error("selectHeroByGates: empty input");
1191
+ const gates = [];
1192
+ if (ctx.requireCancellation) {
1193
+ gates.push({
1194
+ name: "refund",
1195
+ pred: (o) => o.conditions?.refund_before_departure === "allowed"
1196
+ });
1197
+ }
1198
+ if (ctx.requireBag) {
1199
+ gates.push({
1200
+ name: "bag",
1201
+ pred: (o) => o.ancillaries?.checked_bag?.included === true
1202
+ });
1203
+ }
1204
+ const timePreds = [];
1205
+ if (ctx.depTimePref) {
1206
+ timePreds.push((o) => scoreDepTime(isoToMins(o.departure_time), ctx.depTimePref) >= 0.55);
1207
+ }
1208
+ if (ctx.retTimePref) {
1209
+ timePreds.push((o) => {
1210
+ const retDep = o.inbound?.departure_time;
1211
+ if (!retDep) return true;
1212
+ return scoreDepTime(isoToMins(retDep), ctx.retTimePref) >= 0.55;
1213
+ });
1214
+ }
1215
+ if (ctx.arrivalTimePref) {
1216
+ const tripPurposes = normalizeTripPurposes({
1217
+ tripPurpose: ctx.tripPurpose,
1218
+ tripPurposes: ctx.tripPurposes
1219
+ });
1220
+ timePreds.push((o) => {
1221
+ const arrMins = isoToMins(o.arrival_time);
1222
+ const dayOffset = daysBetween(o.departure_time, o.arrival_time);
1223
+ return scoreArrivalTime(arrMins, ctx.arrivalTimePref, tripPurposes, dayOffset) >= 0.55;
1224
+ });
1225
+ }
1226
+ if (timePreds.length > 0) {
1227
+ gates.push({ name: "time", pred: (o) => timePreds.every((p) => p(o)) });
1228
+ }
1229
+ if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
1230
+ const cap = ctx.maxStops;
1231
+ gates.push({ name: "max_stops", pred: (o) => combinedStops(o) <= cap });
1232
+ }
1233
+ if (ctx.preferDirect) {
1234
+ gates.push({ name: "direct", pred: (o) => combinedStops(o) === 0 });
1235
+ }
1236
+ gates.push({ name: "not_suspect", pred: (o) => o.quality !== "suspect" });
1237
+ const topByScore = sorted.reduce((best, s) => s.score > best.score ? s : best, sorted[0]);
1238
+ const gateByName = new Map(gates.map((g) => [g.name, g.pred]));
1239
+ const trulyRelaxed = (names, hero) => names.filter((name) => {
1240
+ const pred = gateByName.get(name);
1241
+ return pred ? !pred(hero.offer) : true;
1242
+ });
1243
+ const relaxed = [];
1244
+ for (let dropCount = 0; dropCount <= gates.length; dropCount++) {
1245
+ const active = gates.slice(dropCount);
1246
+ const candidate = sorted.find((s) => active.every((g) => g.pred(s.offer)));
1247
+ if (candidate) {
1248
+ if (!relaxed.includes("bag") && ctx.requireBag) {
1249
+ const otherActive = active.filter((g) => g.name !== "bag");
1250
+ const altCandidate = sorted.find((s) => otherActive.every((g) => g.pred(s.offer)));
1251
+ if (altCandidate && altCandidate !== candidate && altCandidate === topByScore) {
1252
+ const ratio = candidate.score / altCandidate.score;
1253
+ if (ratio < 0.75) {
1254
+ relaxed.push("bag");
1255
+ continue;
1256
+ }
1257
+ }
1258
+ }
1259
+ return { hero: candidate, relaxed: trulyRelaxed(relaxed, candidate) };
1260
+ }
1261
+ if (dropCount < gates.length) relaxed.push(gates[dropCount].name);
1262
+ }
1263
+ return { hero: sorted[0], relaxed: trulyRelaxed(relaxed, sorted[0]) };
1264
+ }
1265
+ function rankOffers(offers, ctx, options) {
1266
+ if (offers.length === 0) return [];
1267
+ const pool = options?.skipDedup ? offers : deduplicateOffers(offers);
1268
+ const plausible = pool.filter((o) => {
1269
+ const out = o.duration_minutes ?? 0;
1270
+ if (out < 10 || out > 2880) return false;
1271
+ const inb = o.inbound?.duration_minutes;
1272
+ if (typeof inb === "number" && (inb < 10 || inb > 2880)) return false;
1273
+ return totalRoundTripDuration(o) <= 5760;
1274
+ });
1275
+ const weights = resolveWeights(ctx);
1276
+ const effectivePrice = (o) => o.displayPrice ?? o.price;
1277
+ const prices = plausible.map(effectivePrice);
1278
+ const durations = plausible.map(totalRoundTripDuration);
1279
+ const [pLo, pHi] = p5p95(prices);
1280
+ const [dLo, dHi] = p5p95(durations);
1281
+ const cheapestPrice = Math.min(...prices);
1282
+ const cheapestDirectPrice = ctx.preferDirect ? (() => {
1283
+ const dp = plausible.filter((o) => (o.stops ?? 0) === 0).map(effectivePrice).filter((p) => isFinite(p));
1284
+ return dp.length > 0 ? Math.min(...dp) : cheapestPrice;
1285
+ })() : cheapestPrice;
1286
+ const fastestMins = Math.min(...durations);
1287
+ const outboundDurations = plausible.map((o) => o.duration_minutes);
1288
+ const fastestOutboundMins = outboundDurations.length > 0 ? Math.min(...outboundDurations) : 0;
1289
+ const cheapestQuickPrice = ctx.preferQuickFlight ? (() => {
1290
+ const qp = plausible.filter((o) => totalRoundTripDuration(o) <= fastestMins * 2.5).map(effectivePrice).filter((p) => isFinite(p));
1291
+ return qp.length > 0 ? Math.min(...qp) : cheapestPrice;
1292
+ })() : cheapestPrice;
1293
+ const tripPurposes = normalizeTripPurposes({
1294
+ tripPurpose: ctx.tripPurpose,
1295
+ tripPurposes: ctx.tripPurposes
1296
+ });
1297
+ const scored = plausible.map((offer) => {
1298
+ const depMins = isoToMins(offer.departure_time);
1299
+ const arrMins = isoToMins(offer.arrival_time);
1300
+ const dayOffset = daysBetween(offer.departure_time, offer.arrival_time);
1301
+ const ep = effectivePrice(offer);
1302
+ const bd = {
1303
+ price: scorePrice(ep, pLo, pHi),
1304
+ stops: scoreStops(combinedStops(offer)),
1305
+ duration: scoreDuration(totalRoundTripDuration(offer), dLo, dHi),
1306
+ depTime: scoreDepTime(depMins, ctx.depTimePref),
1307
+ arrivalTime: scoreArrivalTime(arrMins, ctx.arrivalTimePref, tripPurposes, dayOffset),
1308
+ baggage: scoreBaggage(offer, ctx.requireBag),
1309
+ savings: scoreSavings(offer, ctx.travelerCount),
1310
+ comfortHours: scoreComfortHours(depMins),
1311
+ layover: scoreLayover(offer, ctx.preferLongLayover)
1312
+ };
1313
+ const rawScore = (bd.price * weights.price + bd.stops * weights.stops + bd.duration * weights.duration + bd.depTime * weights.depTime + bd.arrivalTime * weights.arrivalTime + bd.baggage * weights.baggage + bd.savings * weights.savings + bd.comfortHours * weights.comfortHours + bd.layover * weights.layover) * 100;
1314
+ const penaltyRef = ctx.preferDirect && (offer.stops ?? 0) === 0 ? cheapestDirectPrice : ctx.preferQuickFlight && totalRoundTripDuration(offer) <= fastestMins * 2.5 ? cheapestQuickPrice : cheapestPrice;
1315
+ const score = rawScore * premiumPenalty(ep, penaltyRef) * timeConstraintPenalty(depMins, ctx.departAfterMins, ctx.departBeforeMins) * depTimeMismatchMultiplier(depMins, ctx.depTimePref) * retDepTimeMismatchMultiplier(offer, ctx.retTimePref) * mealPreferenceMultiplier(offer, ctx.requireMeals) * refundabilityMultiplier(offer, ctx.requireCancellation);
1316
+ return { offer, score, rank: 0, breakdown: bd, heroFacts: [], tradeoffs: [] };
1317
+ });
1318
+ scored.sort((a, b) => {
1319
+ if (ctx.preferDirect) {
1320
+ const aDirect = combinedStops(a.offer) === 0 ? 0 : 1;
1321
+ const bDirect = combinedStops(b.offer) === 0 ? 0 : 1;
1322
+ if (aDirect !== bDirect) return aDirect - bDirect;
1323
+ }
1324
+ if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
1325
+ const aIn = combinedStops(a.offer) <= ctx.maxStops ? 0 : 1;
1326
+ const bIn = combinedStops(b.offer) <= ctx.maxStops ? 0 : 1;
1327
+ if (aIn !== bIn) return aIn - bIn;
1328
+ }
1329
+ const scoreDelta = b.score - a.score;
1330
+ if (Math.abs(scoreDelta) > 1e-3) return scoreDelta;
1331
+ const priceDelta = effectivePrice(a.offer) - effectivePrice(b.offer);
1332
+ if (Math.abs(priceDelta) > 1e-3) return priceDelta;
1333
+ const stopsDelta = a.offer.stops - b.offer.stops;
1334
+ if (stopsDelta !== 0) return stopsDelta;
1335
+ const durationDelta = a.offer.duration_minutes - b.offer.duration_minutes;
1336
+ if (durationDelta !== 0) return durationDelta;
1337
+ const depDelta = isoToMins(a.offer.departure_time) - isoToMins(b.offer.departure_time);
1338
+ if (depDelta !== 0) return depDelta;
1339
+ return a.offer.id.localeCompare(b.offer.id);
1340
+ });
1341
+ if (scored.length > 0) {
1342
+ const { hero, relaxed } = selectHeroByGates(scored, ctx);
1343
+ const idx = scored.indexOf(hero);
1344
+ if (idx > 0) {
1345
+ scored.splice(idx, 1);
1346
+ scored.unshift(hero);
1347
+ }
1348
+ if (relaxed.length > 0) scored[0].relaxedGates = relaxed;
1349
+ }
1350
+ const heroPrice = scored[0]?.offer.price ?? cheapestPrice;
1351
+ for (let i = 0; i < scored.length; i++) {
1352
+ scored[i].rank = i + 1;
1353
+ const isHero = i === 0;
1354
+ const refPrice = isHero ? cheapestPrice : heroPrice;
1355
+ const { heroFacts, tradeoffs } = generateFacts(
1356
+ scored[i].offer,
1357
+ scored[i].breakdown,
1358
+ refPrice,
1359
+ fastestOutboundMins,
1360
+ ctx,
1361
+ ctx.travelerCount,
1362
+ isHero
1363
+ );
1364
+ scored[i].heroFacts = heroFacts;
1365
+ scored[i].tradeoffs = tradeoffs;
1366
+ }
1367
+ return scored;
1368
+ }
1369
+ function selectDiverseTop(ranked, n) {
1370
+ if (ranked.length === 0) return [];
1371
+ const result = [ranked[0]];
1372
+ const depSlot = (iso) => Math.floor(isoToMins(iso) / 180);
1373
+ for (const candidate of ranked.slice(1)) {
1374
+ if (result.length >= n) break;
1375
+ const isDiverse = result.every(
1376
+ (sel) => Math.abs(depSlot(candidate.offer.departure_time) - depSlot(sel.offer.departure_time)) >= 1 || Math.abs(candidate.offer.stops - sel.offer.stops) >= 1
1377
+ );
1378
+ if (isDiverse) result.push(candidate);
1379
+ }
1380
+ for (const candidate of ranked.slice(1)) {
1381
+ if (result.length >= n) break;
1382
+ if (!result.some((r) => getOfferInstanceKey(r.offer) === getOfferInstanceKey(candidate.offer))) {
1383
+ result.push(candidate);
1384
+ }
1385
+ }
1386
+ return result;
1387
+ }
1388
+ function getProfileLabel(ctx) {
1389
+ const tripPurpose = getPrimaryTripPurpose({
1390
+ tripPurpose: ctx.tripPurpose,
1391
+ tripPurposes: ctx.tripPurposes
1392
+ });
1393
+ if (tripPurpose === "honeymoon") return "Honeymoon";
1394
+ if (tripPurpose === "special_occasion") return "Special occasion";
1395
+ if (tripPurpose === "business") return "Business travel";
1396
+ if (tripPurpose === "ski") return "Ski trip";
1397
+ if (tripPurpose === "beach") return "Beach holiday";
1398
+ if (tripPurpose === "city_break") return "City break";
1399
+ if (tripPurpose === "family_holiday") return "Family holiday";
1400
+ if (tripPurpose === "graduation") return "Graduation trip";
1401
+ if (tripPurpose === "concert_festival") return "Concert / festival";
1402
+ if (tripPurpose === "sports_event") return "Sports event";
1403
+ if (tripPurpose === "spring_break") return "Spring break";
1404
+ if (ctx.tripContext === "family") return "Family trip";
1405
+ if (ctx.tripContext === "couple") return "Couple";
1406
+ if (ctx.tripContext === "business_traveler") return "Business traveler";
1407
+ if (ctx.requireBag) return "Checked bag needed";
1408
+ return null;
1409
+ }
1410
+
1411
+ // src/index.ts
1412
+ var ErrorCode = {
1413
+ // Transient (safe to retry after short delay)
1414
+ SUPPLIER_TIMEOUT: "SUPPLIER_TIMEOUT",
1415
+ RATE_LIMITED: "RATE_LIMITED",
1416
+ SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
1417
+ NETWORK_ERROR: "NETWORK_ERROR",
1418
+ // Validation (fix input, then retry)
1419
+ INVALID_IATA: "INVALID_IATA",
1420
+ INVALID_DATE: "INVALID_DATE",
1421
+ INVALID_PASSENGERS: "INVALID_PASSENGERS",
1422
+ UNSUPPORTED_ROUTE: "UNSUPPORTED_ROUTE",
1423
+ MISSING_PARAMETER: "MISSING_PARAMETER",
1424
+ INVALID_PARAMETER: "INVALID_PARAMETER",
1425
+ // Business (requires human decision)
1426
+ AUTH_INVALID: "AUTH_INVALID",
1427
+ PAYMENT_REQUIRED: "PAYMENT_REQUIRED",
1428
+ PAYMENT_DECLINED: "PAYMENT_DECLINED",
1429
+ OFFER_EXPIRED: "OFFER_EXPIRED",
1430
+ OFFER_NOT_UNLOCKED: "OFFER_NOT_UNLOCKED",
1431
+ FARE_CHANGED: "FARE_CHANGED",
1432
+ ALREADY_BOOKED: "ALREADY_BOOKED",
1433
+ BOOKING_FAILED: "BOOKING_FAILED"
1434
+ };
1435
+ var ErrorCategory = {
1436
+ TRANSIENT: "transient",
1437
+ VALIDATION: "validation",
1438
+ BUSINESS: "business"
1439
+ };
1440
+ var CODE_TO_CATEGORY = {
1441
+ [ErrorCode.SUPPLIER_TIMEOUT]: ErrorCategory.TRANSIENT,
1442
+ [ErrorCode.RATE_LIMITED]: ErrorCategory.TRANSIENT,
1443
+ [ErrorCode.SERVICE_UNAVAILABLE]: ErrorCategory.TRANSIENT,
1444
+ [ErrorCode.NETWORK_ERROR]: ErrorCategory.TRANSIENT,
1445
+ [ErrorCode.INVALID_IATA]: ErrorCategory.VALIDATION,
1446
+ [ErrorCode.INVALID_DATE]: ErrorCategory.VALIDATION,
1447
+ [ErrorCode.INVALID_PASSENGERS]: ErrorCategory.VALIDATION,
1448
+ [ErrorCode.UNSUPPORTED_ROUTE]: ErrorCategory.VALIDATION,
1449
+ [ErrorCode.MISSING_PARAMETER]: ErrorCategory.VALIDATION,
1450
+ [ErrorCode.INVALID_PARAMETER]: ErrorCategory.VALIDATION,
1451
+ [ErrorCode.AUTH_INVALID]: ErrorCategory.BUSINESS,
1452
+ [ErrorCode.PAYMENT_REQUIRED]: ErrorCategory.BUSINESS,
1453
+ [ErrorCode.PAYMENT_DECLINED]: ErrorCategory.BUSINESS,
1454
+ [ErrorCode.OFFER_EXPIRED]: ErrorCategory.BUSINESS,
1455
+ [ErrorCode.OFFER_NOT_UNLOCKED]: ErrorCategory.BUSINESS,
1456
+ [ErrorCode.FARE_CHANGED]: ErrorCategory.BUSINESS,
1457
+ [ErrorCode.ALREADY_BOOKED]: ErrorCategory.BUSINESS,
1458
+ [ErrorCode.BOOKING_FAILED]: ErrorCategory.BUSINESS
1459
+ };
1460
+ function inferErrorCode(statusCode, detail) {
1461
+ const d = detail.toLowerCase();
1462
+ if (statusCode === 401) return ErrorCode.AUTH_INVALID;
1463
+ if (statusCode === 402) return d.includes("declined") ? ErrorCode.PAYMENT_DECLINED : ErrorCode.PAYMENT_REQUIRED;
1464
+ if (statusCode === 410) return ErrorCode.OFFER_EXPIRED;
1465
+ if (statusCode === 422) {
1466
+ if (d.includes("iata") || d.includes("airport")) return ErrorCode.INVALID_IATA;
1467
+ if (d.includes("date")) return ErrorCode.INVALID_DATE;
1468
+ if (d.includes("passenger")) return ErrorCode.INVALID_PASSENGERS;
1469
+ if (d.includes("route")) return ErrorCode.UNSUPPORTED_ROUTE;
1470
+ return ErrorCode.INVALID_PARAMETER;
1471
+ }
1472
+ if (statusCode === 429) return ErrorCode.RATE_LIMITED;
1473
+ if (statusCode === 503) return ErrorCode.SERVICE_UNAVAILABLE;
1474
+ if (statusCode === 504) return ErrorCode.SUPPLIER_TIMEOUT;
1475
+ if (statusCode === 409) return ErrorCode.ALREADY_BOOKED;
1476
+ return statusCode >= 500 ? ErrorCode.BOOKING_FAILED : ErrorCode.INVALID_PARAMETER;
1477
+ }
1478
+ var LetsFGError = class extends Error {
1479
+ statusCode;
1480
+ response;
1481
+ errorCode;
1482
+ errorCategory;
1483
+ isRetryable;
1484
+ constructor(message, statusCode = 0, response = {}, errorCode = "") {
1485
+ super(message);
1486
+ this.name = "LetsFGError";
1487
+ this.statusCode = statusCode;
1488
+ this.response = response;
1489
+ this.errorCode = errorCode || response.error_code || "";
1490
+ this.errorCategory = CODE_TO_CATEGORY[this.errorCode] || ErrorCategory.BUSINESS;
1491
+ this.isRetryable = this.errorCategory === ErrorCategory.TRANSIENT;
1492
+ }
1493
+ };
1494
+ var AuthenticationError = class extends LetsFGError {
1495
+ constructor(message, response = {}) {
1496
+ super(message, 401, response, ErrorCode.AUTH_INVALID);
1497
+ this.name = "AuthenticationError";
1498
+ }
1499
+ };
1500
+ var PaymentRequiredError = class extends LetsFGError {
1501
+ constructor(message, response = {}) {
1502
+ const code = message.toLowerCase().includes("declined") ? ErrorCode.PAYMENT_DECLINED : ErrorCode.PAYMENT_REQUIRED;
1503
+ super(message, 402, response, code);
1504
+ this.name = "PaymentRequiredError";
1505
+ }
1506
+ };
1507
+ var OfferExpiredError = class extends LetsFGError {
1508
+ constructor(message, response = {}) {
1509
+ super(message, 410, response, ErrorCode.OFFER_EXPIRED);
1510
+ this.name = "OfferExpiredError";
1511
+ }
1512
+ };
1513
+ var ValidationError = class extends LetsFGError {
1514
+ constructor(message, statusCode = 422, response = {}, errorCode = "") {
1515
+ super(message, statusCode, response, errorCode || ErrorCode.INVALID_PARAMETER);
1516
+ this.name = "ValidationError";
1517
+ }
1518
+ };
1519
+ function routeStr(route) {
1520
+ if (!route.segments.length) return "";
1521
+ const codes = [route.segments[0].origin, ...route.segments.map((s) => s.destination)];
1522
+ return codes.join(" -> ");
1523
+ }
1524
+ function durationHuman(seconds) {
1525
+ const h = Math.floor(seconds / 3600);
1526
+ const m = Math.floor(seconds % 3600 / 60);
1527
+ return `${h}h${m.toString().padStart(2, "0")}m`;
1528
+ }
1529
+ function offerSummary(offer) {
1530
+ const route = routeStr(offer.outbound);
1531
+ const dur = durationHuman(offer.outbound.total_duration_seconds);
1532
+ const airline = offer.owner_airline || offer.airlines[0] || "?";
1533
+ return `${offer.currency} ${offer.price.toFixed(2)} | ${airline} | ${route} | ${dur} | ${offer.outbound.stopovers} stop(s)`;
1534
+ }
1535
+ function cheapestOffer(result) {
1536
+ if (!result.offers.length) return null;
1537
+ return result.offers.reduce((min, o) => o.price < min.price ? o : min, result.offers[0]);
1538
+ }
1539
+ var DEFAULT_BASE_URL = "https://letsfg.co";
1540
+ var PFS_POLL_INTERVAL_MS = 1e4;
1541
+ var PFS_POLL_TIMEOUT_MS = 12e4;
1542
+ var LetsFG = class {
1543
+ bearerToken;
1544
+ apiKey;
1545
+ baseUrl;
1546
+ timeout;
1547
+ constructor(config = {}) {
1548
+ this.bearerToken = config.bearerToken || process.env.LETSFG_BEARER_TOKEN || "";
1549
+ this.apiKey = config.apiKey || process.env.LETSFG_API_KEY || "";
1550
+ this.baseUrl = (config.baseUrl || process.env.LETSFG_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
1551
+ this.timeout = config.timeout || 3e4;
1552
+ }
1553
+ requireAuth() {
1554
+ if (!this.bearerToken && !this.apiKey) {
1555
+ throw new AuthenticationError(
1556
+ "Authentication required. Set bearerToken (from `letsfg auth`) or apiKey in config, or set LETSFG_BEARER_TOKEN / LETSFG_API_KEY env vars."
1557
+ );
1558
+ }
1559
+ }
1560
+ requireApiKey() {
1561
+ if (!this.apiKey) {
1562
+ throw new AuthenticationError(
1563
+ "Developer API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var. Register at letsfg.co/developers."
1564
+ );
1565
+ }
1566
+ }
1567
+ /** True when using PFS Bearer token (free search path) */
1568
+ get usingPFS() {
1569
+ return !!this.bearerToken;
1570
+ }
1571
+ // ── Core methods ─────────────────────────────────────────────────────
1572
+ /**
1573
+ * Search for flights — FREE.
1574
+ *
1575
+ * Uses PFS (Bearer token) or Developer API (X-API-Key) depending on config.
1576
+ * PFS: async polling (POST /api/search -> poll /api/results/<id> every 10s).
1577
+ * Developer API: synchronous 60-90s call.
1578
+ *
1579
+ * @param origin - IATA code (e.g., "GDN", "LON")
1580
+ * @param destination - IATA code (e.g., "BER", "BCN")
1581
+ * @param dateFrom - Departure date "YYYY-MM-DD"
1582
+ * @param options - Optional search parameters
1583
+ */
1584
+ async search(origin, destination, dateFrom, options = {}) {
1585
+ this.requireAuth();
1586
+ const body = {
1587
+ origin: origin.toUpperCase(),
1588
+ destination: destination.toUpperCase(),
1589
+ date_from: dateFrom,
1590
+ adults: options.adults ?? 1,
1591
+ children: options.children ?? 0,
1592
+ currency: options.currency ?? "EUR",
1593
+ limit: options.limit ?? 50
1594
+ };
1595
+ if (options.returnDate) body.return_date = options.returnDate;
1596
+ if (options.cabinClass) body.cabin_class = options.cabinClass;
1597
+ if (options.maxStopovers != null) body.max_stopovers = options.maxStopovers;
1598
+ if (options.sort) body.sort = options.sort;
1599
+ if (this.usingPFS) {
1600
+ return this.searchPFS(body);
1601
+ }
1602
+ return this.post("/developers/api/v1/flights/search", body);
1603
+ }
1604
+ /** PFS path: POST /api/search -> poll /api/results/<id> */
1605
+ async searchPFS(body) {
1606
+ const { search_id } = await this.postWithBearer("/api/search", body);
1607
+ const deadline = Date.now() + PFS_POLL_TIMEOUT_MS;
1608
+ while (Date.now() < deadline) {
1609
+ await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
1610
+ const result = await this.getNoAuth(
1611
+ `/api/results/${search_id}`
1612
+ );
1613
+ if (result.status !== "pending") {
1614
+ return result;
1615
+ }
1616
+ }
1617
+ throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
1618
+ }
1619
+ /**
1620
+ * Resolve a city/airport name to IATA codes.
1621
+ */
1622
+ async resolveLocation(query) {
1623
+ this.requireAuth();
1624
+ const path = this.usingPFS ? `/api/locations?q=${encodeURIComponent(query)}` : `/developers/api/v1/flights/locations/${encodeURIComponent(query)}`;
1625
+ const data = await this.getWithAuth(path);
1626
+ return Array.isArray(data) ? data : data.locations || [];
1627
+ }
1628
+ /**
1629
+ * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
1630
+ * Cost: 1% of ticket price, min $3. Free with Developer API.
1631
+ */
1632
+ async unlock(offerId) {
1633
+ this.requireAuth();
1634
+ const path = this.usingPFS ? "/api/unlock" : "/developers/api/v1/bookings/unlock";
1635
+ return this.postWithAuth(path, { offer_id: offerId });
1636
+ }
1637
+ /**
1638
+ * Book a flight via Developer API — charges ticket price via Stripe, creates real PNR.
1639
+ * Always provide idempotencyKey to prevent double-bookings on retry.
1640
+ */
1641
+ async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
1642
+ this.requireApiKey();
1643
+ const body = {
1644
+ offer_id: offerId,
1645
+ booking_type: "flight",
1646
+ passengers,
1647
+ contact_email: contactEmail,
1648
+ contact_phone: contactPhone
1649
+ };
1650
+ if (idempotencyKey) body.idempotency_key = idempotencyKey;
1651
+ return this.post("/developers/api/v1/bookings/book", body);
1652
+ }
1653
+ /**
1654
+ * Set up payment method (required before unlock/booking).
1655
+ */
1656
+ async setupPayment(token = "tok_visa") {
1657
+ this.requireApiKey();
1658
+ return this.post("/developers/api/v1/agents/setup-payment", { token });
1659
+ }
1660
+ /**
1661
+ * Get current agent profile and usage stats.
1662
+ */
1663
+ async me() {
1664
+ this.requireApiKey();
1665
+ return this.get("/developers/api/v1/agents/me");
1666
+ }
1667
+ // ── Static methods ───────────────────────────────────────────────────
1668
+ /**
1669
+ * Register a new Developer API agent — no auth needed.
1670
+ */
1671
+ static async register(agentName, email, baseUrl, ownerName = "", description = "") {
1672
+ const url = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
1673
+ const resp = await fetch(`${url}/developers/api/v1/agents/register`, {
1674
+ method: "POST",
1675
+ headers: { "Content-Type": "application/json" },
1676
+ body: JSON.stringify({ agent_name: agentName, email, owner_name: ownerName, description })
1677
+ });
1678
+ const data = await resp.json();
1679
+ if (!resp.ok) {
1680
+ throw new LetsFGError(
1681
+ data.detail || `Registration failed (${resp.status})`,
1682
+ resp.status,
1683
+ data
1684
+ );
1685
+ }
1686
+ return data;
1687
+ }
1688
+ // ── Internal ────────────────────────────────────────────────────────
1689
+ async postWithBearer(path, body) {
1690
+ return this.requestWithHeaders(path, "POST", { "Authorization": `Bearer ${this.bearerToken}` }, body);
1691
+ }
1692
+ async getNoAuth(path) {
1693
+ return this.requestWithHeaders(path, "GET", {});
1694
+ }
1695
+ async getWithAuth(path) {
1696
+ const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
1697
+ return this.requestWithHeaders(path, "GET", headers);
1698
+ }
1699
+ async postWithAuth(path, body) {
1700
+ const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
1701
+ return this.requestWithHeaders(path, "POST", headers, body);
1702
+ }
1703
+ async post(path, body) {
1704
+ return this.requestWithHeaders(path, "POST", { "X-API-Key": this.apiKey }, body);
1705
+ }
1706
+ async get(path) {
1707
+ return this.requestWithHeaders(path, "GET", { "X-API-Key": this.apiKey });
1708
+ }
1709
+ async requestWithHeaders(path, method, extraHeaders, body) {
1710
+ const controller = new AbortController();
1711
+ const timer = setTimeout(() => controller.abort(), this.timeout);
1712
+ try {
1713
+ const resp = await fetch(`${this.baseUrl}${path}`, {
1714
+ method,
1715
+ headers: {
1716
+ "Content-Type": "application/json",
1717
+ "User-Agent": "LetsFG-js/0.1.0",
1718
+ "X-Client-Type": "js-sdk",
1719
+ ...extraHeaders
1720
+ },
1721
+ ...body != null ? { body: JSON.stringify(body) } : {},
1722
+ signal: controller.signal
1723
+ });
1724
+ const data = await resp.json();
1725
+ if (!resp.ok) {
1726
+ const detail = data.detail || `API error (${resp.status})`;
1727
+ const code = data.error_code || inferErrorCode(resp.status, detail);
1728
+ if (resp.status === 401) throw new AuthenticationError(detail, data);
1729
+ if (resp.status === 402) throw new PaymentRequiredError(detail, data);
1730
+ if (resp.status === 410) throw new OfferExpiredError(detail, data);
1731
+ if (resp.status === 422) throw new ValidationError(detail, resp.status, data, code);
1732
+ throw new LetsFGError(detail, resp.status, data, code);
1733
+ }
1734
+ return data;
1735
+ } finally {
1736
+ clearTimeout(timer);
1737
+ }
1738
+ }
1739
+ };
1740
+ var index_default = LetsFG;
1741
+ var BoostedTravel = LetsFG;
1742
+ var BoostedTravelError = LetsFGError;
1743
+
1744
+ export {
1745
+ extractOfferDetailSignals,
1746
+ getOfferDetailBadges,
1747
+ getOfferDetailPromptNotes,
1748
+ TRIP_PURPOSES,
1749
+ normalizeTripPurposes,
1750
+ getPrimaryTripPurpose,
1751
+ deduplicateOffers,
1752
+ rankOffers,
1753
+ selectDiverseTop,
1754
+ getProfileLabel,
1755
+ ErrorCode,
1756
+ ErrorCategory,
1757
+ LetsFGError,
1758
+ AuthenticationError,
1759
+ PaymentRequiredError,
1760
+ OfferExpiredError,
1761
+ ValidationError,
1762
+ offerSummary,
1763
+ cheapestOffer,
1764
+ LetsFG,
1765
+ index_default,
1766
+ BoostedTravel,
1767
+ BoostedTravelError
1768
+ };