From 18b966bfa3eb9cc128b5e498bd27c9453a4474a8 Mon Sep 17 00:00:00 2001 From: Felix <23635466+its-felix@users.noreply.github.com> Date: Sat, 14 Dec 2024 02:15:08 +0100 Subject: [PATCH] update m&m quick search for the new v3 api --- ui/src/lib/milesandmore/client.ts | 74 +++--- ui/src/pages/tools/mm-quick-search.tsx | 334 +++++++++++++++++++------ 2 files changed, 295 insertions(+), 113 deletions(-) diff --git a/ui/src/lib/milesandmore/client.ts b/ui/src/lib/milesandmore/client.ts index 822b59c..b967b03 100644 --- a/ui/src/lib/milesandmore/client.ts +++ b/ui/src/lib/milesandmore/client.ts @@ -9,11 +9,7 @@ export enum FareFamily { } export enum CorporateCode { - LH = '223293', -} - -export enum CompanyCode { - LH = 'LH', + LH = 223293, } export enum PassengerCode { @@ -29,47 +25,32 @@ export enum Mode { BEST_BY_DAY = 'bestByDay', } -export interface FrequentFlyer { - companyCode: CompanyCode; - priorityCode: number; -} - export interface Itinerary { - departureDateTime?: string; + departureDateTime: string; originLocationCode: string; destinationLocationCode: string; } -export interface SearchPreferences { - mode: Mode; - showMilesPrice: boolean; -} - -export interface Traveler { - passengerTypeCode: PassengerCode; -} - export interface TripDetails { - tripDuration: number; + tripDuration?: number; rangeOfDeparture: number; } export interface BestByRequest { commercialFareFamilies: ReadonlyArray; corporateCodes: ReadonlyArray; + countryOfCommencement: 'DE', currencyCode: CurrencyCode; - frequentFlyer: FrequentFlyer; itineraries: ReadonlyArray; - searchPreferences: SearchPreferences; - travelers: ReadonlyArray; - tripDetails?: TripDetails; + tripDetails: TripDetails; } export interface MMRequest { mode: Mode; fareFamily: FareFamily; travelers: ReadonlyArray; - departureDateTime: DateTime; + minDepartureDateTime: DateTime; + maxDepartureDateTime: DateTime; origin: string; destination: string; } @@ -96,15 +77,12 @@ export interface Flight { export interface Bound { fareFamilyCode: string; - originLocationCode: string; - destinationLocationCode: string; - flights: ReadonlyArray; + flights: ReadonlyArray<{}>; } export interface MilesConversion { convertedMiles: { base: number; - total: number; }; } @@ -122,13 +100,19 @@ export interface ResponseDataEntry { departureDate: string; fareFamilyCode: string; bounds: ReadonlyArray; - fareInfos: ReadonlyArray; + fareInfos: ReadonlyArray<{}>; prices: Prices; } +export interface Currency { + name: string; + decimalPlaces: number; +} + export interface ResponseDataDictionaries { aircraft: Record; airline: Record; + currency: Record; flight: Record; } @@ -151,38 +135,43 @@ export class MilesAndMoreClient { } async getBestBy(req: MMRequest): Promise { + const today = DateTime.now(); + let minDepartureDateTime = req.minDepartureDateTime; + if (today > minDepartureDateTime) { + minDepartureDateTime = today; + } + const request = { commercialFareFamilies: [req.fareFamily], corporateCodes: [CorporateCode.LH], + countryOfCommencement: 'DE', currencyCode: CurrencyCode.EUR, - frequentFlyer: { - companyCode: CompanyCode.LH, - priorityCode: 0, - }, itineraries: [ { - departureDateTime: req.departureDateTime.toISODate() + 'T00:00:00', + departureDateTime: minDepartureDateTime.toISODate() + 'T00:00:00', originLocationCode: req.origin, destinationLocationCode: req.destination, }, ], - searchPreferences: { - mode: req.mode, - showMilesPrice: true, + tripDetails: { + rangeOfDeparture: Math.ceil(minDepartureDateTime.until(req.maxDepartureDateTime).length('days')), }, - travelers: req.travelers.map((v) => ({ passengerTypeCode: v })), } satisfies BestByRequest; const errs: Array = []; - for (let i = 0; i < 3; i++) { + const maxAttempts = 1; + for (let i = 0; i < maxAttempts; i++) { const resp = await this.httpClient.fetch( - `http://127.0.0.1:8090/milesandmore/flights/v1/${req.mode === Mode.BEST_BY_MONTH ? 'bestbymonth' : 'bestbyday'}`, + `http://127.0.0.1:8090/milesandmore/flights/v3/${req.mode === Mode.BEST_BY_MONTH ? 'bestbymonth' : 'bestbyday'}`, { method: 'POST', body: JSON.stringify(request), headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', 'X-Api-Key': 'agGBZmuTGwFXWzVDg8ckGKGBytemE1nS', + 'Rtw': 'true', }, }, ); @@ -195,6 +184,7 @@ export class MilesAndMoreClient { dictionaries: { aircraft: {}, airline: {}, + currency: {}, flight: {}, }, }; diff --git a/ui/src/pages/tools/mm-quick-search.tsx b/ui/src/pages/tools/mm-quick-search.tsx index 1788635..b8fcdf2 100644 --- a/ui/src/pages/tools/mm-quick-search.tsx +++ b/ui/src/pages/tools/mm-quick-search.tsx @@ -2,16 +2,14 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useHttpClient } from '../../components/util/context/http-client'; import { ArrivalDeparture, - Bound, - FareFamily, + FareFamily, FlightLookup, MilesAndMoreClient, MMResponse, Mode, PassengerCode, - ResponseDataDictionaries, ResponseDataEntry } from '../../lib/milesandmore/client'; import { - Alert, + Alert, Box, Button, ColumnLayout, Container, @@ -20,16 +18,54 @@ import { Form, FormField, Grid, - Header, Link, Table + Header, Link, Select, SelectProps, Table } from '@cloudscape-design/components'; import { AirportMultiselect } from '../../components/select/airport-multiselect'; -import { DateTime, Duration } from 'luxon'; +import { DateTime } from 'luxon'; import { catchNotify, useAppControls } from '../../components/util/context/app-controls'; import { useCollection } from '@cloudscape-design/collection-hooks'; import { useInterval } from '../../components/util/state/common'; import { useAirports } from '../../components/util/state/data'; import { FlightLink } from '../../components/common/flight-link'; import { withDepartureAirportFilter, withDepartureDateFilter } from '../flight'; +import { BulletSeperator, Join } from '../../components/common/join'; + +const CABIN_OPTIONS = [ + { + label: 'Economy', + description: FareFamily.ECO, + value: FareFamily.ECO, + }, + { + label: 'Premium Economy', + description: FareFamily.PRECO, + value: FareFamily.PRECO, + }, + { + label: 'Business', + description: FareFamily.BUSINESS, + value: FareFamily.BUSINESS, + }, + { + label: 'First', + description: FareFamily.FIRST, + value: FareFamily.FIRST, + }, +] satisfies ReadonlyArray; + +interface MmSearchMatch { + legs: ReadonlyArray; + miles: number; + cash: ReadonlyArray<[string, string]>; +} + +interface MmSearchMatchLeg { + departureTime: DateTime; + departureAirport: string; + arrivalTime: DateTime; + arrivalAirport: string; + flight: FlightLookup; +} export function MmQuickSearch() { const { httpClient } = useHttpClient(); @@ -39,41 +75,39 @@ export function MmQuickSearch() { const airportsQuery = useAirports(); const [isLoading, setLoading] = useState(false); + const [cabin, setCabin] = useState(CABIN_OPTIONS[2]); const [origins, setOrigins] = useState>([]); const [destinations, setDestinations] = useState>([]); const [minDeparture, setMinDeparture] = useState>(DateTime.now().startOf('day')); - const [maxDeparture, setMaxDeparture] = useState>(DateTime.now().endOf('day')); + const [maxDeparture, setMaxDeparture] = useState>(minDeparture.endOf('month')); - const [items, setItems] = useState>([]); + const [items, setItems] = useState>([]); function onSearch() { setLoading(true); (async () => { - const promises: Array> = []; - const start = minDeparture.startOf('month'); - const end = maxDeparture.startOf('month'); + const promises: Array<[string, string, Promise]> = []; + const fareFamily = cabin.value as FareFamily; for (const origin of origins) { for (const destination of destinations) { - let curr = start; - do { - const promise = mmClient.getBestBy({ - mode: Mode.BEST_BY_DAY, - fareFamily: FareFamily.BUSINESS, - travelers: [PassengerCode.ADULT], - departureDateTime: curr, - origin: origin, - destination: destination, - }); - - promises.push(promise); - - curr = curr.plus(Duration.fromMillis(1000 * 60 * 60 * 24 * 32)).startOf('month'); - } while (end.diff(curr).toMillis() > 0); + const promise = mmClient.getBestBy({ + mode: Mode.BEST_BY_DAY, + fareFamily: fareFamily, + travelers: [PassengerCode.ADULT], + minDepartureDateTime: minDeparture, + maxDepartureDateTime: maxDeparture, + origin: origin, + destination: destination, + }); + + promises.push([origin, destination, promise]); } } - for (const promise of promises) { + const allErrors: Array = []; + + for (const [origin, destination, promise] of promises) { let res: MMResponse; try { res = await promise; @@ -82,24 +116,23 @@ export function MmQuickSearch() { continue; } - for (let d of res.data) { - const filteredBounds: Array = []; - for (const bound of d.bounds) { - if (bound.flights.length >= 1) { - const flight = res.dictionaries.flight[bound.flights[0].id]; - const departure = DateTime.fromISO(flight.departure.dateTime, { setZone: true }); + const [entries, errors] = mmResponseToEntries(origin, destination, res); + allErrors.push(...errors); - if (departure.isValid && departure >= minDeparture && departure <= maxDeparture) { - filteredBounds.push(bound); - } - } - } + setItems((prev) => [...prev, ...entries]); + } - if (filteredBounds.length >= 1) { - d = { ...d, bounds: filteredBounds }; - setItems((prev) => [...prev, { entry: d, dictionaries: res.dictionaries }]); - } - } + if (allErrors.length > 0) { + notification.addOnce({ + type: 'warning', + header: 'Could not display all matches', + content: ( + + {...allErrors.map((v) => {v})} + + ), + dismissible: true, + }); } })() .catch(catchNotify(notification)) @@ -117,9 +150,19 @@ export function MmQuickSearch() { gridDefinition={[ { colspan: { default: 12, xs: 6, m: 3 } }, { colspan: { default: 12, xs: 6, m: 3 } }, - { colspan: { default: 12, xs: 12, m: 6 } }, + { colspan: { default: 12, xs: 6, m: 3 } }, + { colspan: { default: 12, xs: 12, m: 3 } }, ]} > + +