From 38b66905421dcf8e4e172ea5eb3a0abd44a3a7de Mon Sep 17 00:00:00 2001 From: CarloBu Date: Sat, 21 Dec 2024 23:56:19 +0200 Subject: [PATCH] pricing calculation fixes --- .npmrc | 3 - README.md | 4 +- src/components/react/FlightResults.tsx | 239 ++++++++++++++++-- src/components/react/FlightSearch.tsx | 6 +- src/components/react/modals/MultiComboBox.tsx | 16 +- src/layouts/Layout.astro | 2 +- src/pages/index.astro | 36 ++- src/utils/flightUtils.ts | 17 +- 8 files changed, 265 insertions(+), 58 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e6edee3..0000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -always-auth=true -//npm.greensock.com/:_authToken=4290db7e-0d2d-4ba7-92e1-bfada85d5aa3 -@gsap:registry=https://npm.greensock.com \ No newline at end of file diff --git a/README.md b/README.md index 64bd13f..90511e4 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ The main endpoint `/api/search-flights` accepts the following parameters: - `originAirports`: Comma-separated airport codes - `wantedCountries`: Comma-separated country names - `adults`: Number of adult passengers +- `teens`: Number of teen passengers - `children`: Number of child passengers -- `infants`: Number of infant passengers -- `teens`: Number of teen passengers +- `infants`: Number of infant passengers (because ryanair has 25eur flat infant seat fee, the api ignores infants) #### Response Format diff --git a/src/components/react/FlightResults.tsx b/src/components/react/FlightResults.tsx index 4969581..62f4ba0 100644 --- a/src/components/react/FlightResults.tsx +++ b/src/components/react/FlightResults.tsx @@ -5,6 +5,8 @@ import { calculateTripDays, getPriceColor, INFANT_SEAT_PRICE, + RESERVED_SEAT_FEE, + calculateTotalPrice, } from "../../utils/flightUtils"; import { useState, useEffect } from "react"; import { Frown } from "lucide-react"; @@ -25,9 +27,41 @@ export function LoadingIndicator() { return (
-
-
-
+
+ {/* Background ring */} + + + + {/* Spinning orange ring */} + + +

@@ -49,6 +83,10 @@ function MiniFlightCard({ onClick, onFlightClick, tripType = "return", + adults, + teens, + children, + infants, }: { flight: Flight; minPrice: number; @@ -56,6 +94,10 @@ function MiniFlightCard({ onClick?: () => void; onFlightClick?: (flightId: string) => void; tripType?: "oneWay" | "return" | "weekend" | "longWeekend"; + adults: number; + teens: number; + children: number; + infants: number; }) { const [isHovered, setIsHovered] = useState(false); const flightId = `${flight.outbound.origin}-${flight.outbound.destination}-${flight.outbound.departureTime}`; @@ -69,6 +111,15 @@ function MiniFlightCard({ ) : undefined; + const totalPrice = calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + const handleClick = () => { onClick?.(); onFlightClick?.(flightId); @@ -84,7 +135,7 @@ function MiniFlightCard({ }, 100); }; - const priceColor = getPriceColor(flight.totalPrice, minPrice, maxPrice); + const priceColor = getPriceColor(totalPrice, minPrice, maxPrice); const showReturnInfo = tripType === "return" || @@ -155,7 +206,7 @@ function MiniFlightCard({ className="text-[clamp(0.7rem,4vw,1rem)] font-bold xsm:text-[1.1rem]" style={{ color: priceColor.text }} > - €{Math.round(flight.totalPrice)} + €{Math.round(totalPrice)}

@@ -171,15 +222,37 @@ function MiniCityCard({ onCardClick, minPrice, maxPrice, + adults, + teens, + children, + infants, + tripType = "return", }: { city: string; cityData: CityData; onCardClick: () => void; minPrice: number; maxPrice: number; + adults: number; + teens: number; + children: number; + infants: number; + tripType?: "oneWay" | "return" | "weekend" | "longWeekend"; }) { const [isHovered, setIsHovered] = useState(false); - const priceColor = getPriceColor(cityData.minPrice, minPrice, maxPrice); + const cityMinPrice = Math.min( + ...cityData.flights.map((flight) => + calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + tripType, + ), + ), + ); + const priceColor = getPriceColor(cityMinPrice, minPrice, maxPrice); return (
@@ -223,7 +296,7 @@ function MiniCityCard({ className="text-[6vw] font-bold ssm:text-[1.1rem]" style={{ color: priceColor.text }} > - €{Math.round(cityData.minPrice)} + €{Math.round(cityMinPrice)}
@@ -302,10 +375,19 @@ function DetailedFlightCard({ tripType?: "oneWay" | "return" | "weekend" | "longWeekend"; }) { const [isHovered, setIsHovered] = useState(false); - const priceColor = getPriceColor(flight.totalPrice, minPrice, maxPrice); + const totalPrice = calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + const priceColor = getPriceColor(totalPrice, minPrice, maxPrice); const flightId = `${flight.outbound.origin}-${flight.outbound.destination}-${flight.outbound.departureTime}`; - const infantFee = - INFANT_SEAT_PRICE * infants * (tripType === "oneWay" ? 1 : 2); + const isReturn = tripType !== "oneWay"; + const infantFee = INFANT_SEAT_PRICE * (isReturn ? 2 : 1); + const reservedSeatFee = RESERVED_SEAT_FEE * (isReturn ? 2 : 1); const showReturnInfo = tripType === "return" || @@ -430,19 +512,29 @@ function DetailedFlightCard({ className="text-2xl font-bold md:text-3xl" style={{ color: priceColor.text }} > - €{Math.round(flight.totalPrice)} + €{Math.round(totalPrice)}
- {adults > 0 && `${adults} adult${adults !== 1 ? "s" : ""}`} - {teens > 0 && ` • ${teens} teen${teens !== 1 ? "s" : ""}`} - {children > 0 && - ` • ${children} ${children === 1 ? "child" : "children"}`} + {[ + adults > 0 && `${adults} adult${adults !== 1 ? "s" : ""}`, + teens > 0 && `${teens} teen${teens !== 1 ? "s" : ""}`, + children > 0 && + `${children} ${children === 1 ? "child" : "children"}`, + infants > 0 && `${infants} infant${infants !== 1 ? "s" : ""}`, + ] + .filter(Boolean) + .join(" • ")}
- {infants > 0 && ( -
- {infants} infant{infants !== 1 ? "s" : ""} ( - {tripType === "return" ? "2×" : ""}€25 seat fee) + {(infants > 0 || children > 0) && ( +
+ {[ + children > 0 && `Reserved seat €${reservedSeatFee} fee`, + infants > 0 && `infant seat €${infantFee} fee`, + ] + .filter(Boolean) + .join(" and ")}{" "} + {children > 0 && infants > 0 ? "are" : "is"} included
)}
@@ -460,7 +552,7 @@ function DetailedFlightCard({ target="_blank" rel="noopener noreferrer" className="button-animation-subtle group/flight-button relative mt-4 inline-flex w-full items-center justify-center overflow-hidden rounded-full bg-black px-3 py-2 text-sm font-semibold text-white transition-all hover:bg-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800 md:px-4 md:py-2.5" - aria-label={`Book flight from ${flight.outbound.origin} to ${flight.outbound.destination} for €${Math.round(flight.totalPrice)}`} + aria-label={`Book flight from ${flight.outbound.origin} to ${flight.outbound.destination} for €${Math.round(totalPrice)}`} > Book Flight @@ -566,7 +658,25 @@ function CityGroup({ {!isExpanded && (
{cityData.flights - .sort((a, b) => a.totalPrice - b.totalPrice) + .sort((a, b) => { + const totalPriceA = calculateTotalPrice( + a.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + const totalPriceB = calculateTotalPrice( + b.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + return totalPriceA - totalPriceB; + }) .map((flight, index) => (
))} @@ -591,7 +705,25 @@ function CityGroup({ {isExpanded && (
{cityData.flights - .sort((a, b) => a.totalPrice - b.totalPrice) + .sort((a, b) => { + const totalPriceA = calculateTotalPrice( + a.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + const totalPriceB = calculateTotalPrice( + b.totalPrice, + adults, + teens, + children, + infants, + tripType, + ); + return totalPriceA - totalPriceB; + }) .map((flight, index) => { const tripDays = calculateTripDays( flight.outbound.departureTime, @@ -707,8 +839,30 @@ export function FlightResults({ ); // Calculate global price range from filtered flights - const globalMinPrice = Math.min(...validFlights.map((f) => f.totalPrice)); - const globalMaxPrice = Math.max(...validFlights.map((f) => f.totalPrice)); + const globalMinPrice = Math.min( + ...validFlights.map((flight) => + calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + searchParams?.tripType || "return", + ), + ), + ); + const globalMaxPrice = Math.max( + ...validFlights.map((flight) => + calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + searchParams?.tripType || "return", + ), + ), + ); // Group flights by country and city for hierarchical display const groupedFlights = validFlights.reduce( @@ -913,7 +1067,31 @@ export function FlightResults({
{Object.entries(countryData.cities) .sort(([, cityDataA], [, cityDataB]) => { - return cityDataA.minPrice - cityDataB.minPrice; + const cityMinPriceA = Math.min( + ...cityDataA.flights.map((flight) => + calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + searchParams?.tripType || "return", + ), + ), + ); + const cityMinPriceB = Math.min( + ...cityDataB.flights.map((flight) => + calculateTotalPrice( + flight.totalPrice, + adults, + teens, + children, + infants, + searchParams?.tripType || "return", + ), + ), + ); + return cityMinPriceA - cityMinPriceB; }) .map(([city, cityData]) => (
@@ -925,6 +1103,11 @@ export function FlightResults({ } minPrice={globalMinPrice} maxPrice={globalMaxPrice} + adults={adults} + teens={teens} + children={children} + infants={infants} + tripType={searchParams?.tripType || "return"} />
))} @@ -966,6 +1149,12 @@ export function FlightResults({
); })} + + {/* Price change disclaimer */} +
+ Please note that prices may change due to availability at time of + booking. +
); } diff --git a/src/components/react/FlightSearch.tsx b/src/components/react/FlightSearch.tsx index 2dec450..f4a9c88 100644 --- a/src/components/react/FlightSearch.tsx +++ b/src/components/react/FlightSearch.tsx @@ -701,10 +701,6 @@ export default function FlightSearch() { role="search" aria-label="Flight search form" > - - - -
@@ -823,6 +820,7 @@ export default function FlightSearch() { allOptionText="All Countries" className="min-w-[12rem]" ariaLabel="Open destination countries selection" + mobileBreakpoint={423} />
diff --git a/src/components/react/modals/MultiComboBox.tsx b/src/components/react/modals/MultiComboBox.tsx index 73549c6..757421d 100644 --- a/src/components/react/modals/MultiComboBox.tsx +++ b/src/components/react/modals/MultiComboBox.tsx @@ -44,7 +44,8 @@ interface MultiComboboxProps { allOptionText?: string; showCode?: boolean; displayFormat?: (option: Option) => string; - ariaLabel: string; + ariaLabel?: string; + mobileBreakpoint?: number; } export function MultiCombobox({ @@ -60,6 +61,7 @@ export function MultiCombobox({ showCode = false, displayFormat, ariaLabel, + mobileBreakpoint = 419, }: MultiComboboxProps) { const [open, setOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); @@ -86,16 +88,6 @@ export function MultiCombobox({ option.country?.toLowerCase().includes(searchQuery.toLowerCase()) || option.countryCode?.toLowerCase().includes(searchQuery.toLowerCase()); - if ( - searchQuery.toLowerCase() === "sweden" || - searchQuery.toLowerCase() === "se" - ) { - return ( - option.country?.toLowerCase() === "sweden" || - option.countryCode?.toLowerCase() === "se" - ); - } - return ( option.name.toLowerCase().includes(searchQuery.toLowerCase()) || option.code.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -188,7 +180,7 @@ export function MultiCombobox({ }; const renderSelectedValues = () => { - const MAX_VISIBLE_ITEMS = windowWidth > 419 ? 2 : 1; + const MAX_VISIBLE_ITEMS = windowWidth > mobileBreakpoint ? 2 : 1; if (selectedValues.length === 0) { return ( diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index a21f6e0..0c093d4 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -129,7 +129,7 @@ const canonicalURL = new URL( + class='fmb-scrollbar text-body bg-white font-outfit font-medium tracking-wide text-gray-900 antialiased dark:bg-gray-900 dark:text-white'> diff --git a/src/pages/index.astro b/src/pages/index.astro index 60b106d..372816f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -86,13 +86,33 @@ const pageDescription =
- - made by - OAKSUN - + diff --git a/src/utils/flightUtils.ts b/src/utils/flightUtils.ts index 702e61e..975b56d 100644 --- a/src/utils/flightUtils.ts +++ b/src/utils/flightUtils.ts @@ -1,6 +1,7 @@ import type { Flight } from "../types/flight"; export const INFANT_SEAT_PRICE = 25; // EUR per one-way flight +export const RESERVED_SEAT_FEE = 8; // EUR per flight when there are children export function formatDateTime(dateString: string, short: boolean = false) { const date = new Date(dateString); @@ -203,10 +204,20 @@ export function calculateTotalPrice( teens: number, children: number, infants: number, - tripType: "oneWay" | "return" = "return", + tripType: "oneWay" | "return" | "weekend" | "longWeekend" = "return", ): number { + const isReturn = + tripType === "return" || + tripType === "weekend" || + tripType === "longWeekend"; + const infantFee = INFANT_SEAT_PRICE * infants * (tripType === "return" ? 2 : 1); - const passengerPrice = basePrice * (adults + teens + children); - return passengerPrice + infantFee; + + // Add reserved seat fee when there are children + // One reserved seat fee per flight when there are children, regardless of the number of children + const reservedSeatFee = + children > 0 ? RESERVED_SEAT_FEE * (isReturn ? 2 : 1) : 0; + + return basePrice + infantFee + reservedSeatFee; }