From bdc2ea86a2d10db72144bbbc0ec4ed9962943ed5 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 24 Nov 2025 23:43:05 +0100 Subject: [PATCH] feat: huge push of product changes and item review with cart --- backend/server/app.py | 96 ++++++++++++++- web/src/app/airline/products/[id]/page.tsx | 106 +++++++++++++++++ web/src/app/airline/products/page.tsx | 8 +- web/src/app/api/products/[id]/route.ts | 35 ++++++ web/src/app/api/products/route.ts | 11 +- web/src/app/cart/page.tsx | 110 ++++++++++++++++++ web/src/app/hotel/products/[id]/page.tsx | 106 +++++++++++++++++ web/src/app/hotel/products/page.tsx | 8 +- web/src/app/layout.tsx | 5 +- .../components/feats/airline/AirlineCard.tsx | 1 + .../feats/airline/AirlineDetails.tsx | 83 +++++++++++++ .../components/feats/airline/AirlineHero.tsx | 21 +++- web/src/components/feats/hotel/HotelCard.tsx | 1 + .../components/feats/hotel/HotelDetails.tsx | 74 ++++++++++++ web/src/contexts/CartContext.tsx | 76 ++++++++++++ web/src/lib/product-utils.ts | 25 ++++ web/src/proxy.ts | 1 + 17 files changed, 754 insertions(+), 13 deletions(-) create mode 100644 web/src/app/airline/products/[id]/page.tsx create mode 100644 web/src/app/api/products/[id]/route.ts create mode 100644 web/src/app/cart/page.tsx create mode 100644 web/src/app/hotel/products/[id]/page.tsx create mode 100644 web/src/components/feats/airline/AirlineDetails.tsx create mode 100644 web/src/components/feats/hotel/HotelDetails.tsx create mode 100644 web/src/contexts/CartContext.tsx create mode 100644 web/src/lib/product-utils.ts diff --git a/backend/server/app.py b/backend/server/app.py index 8fa0648..2a6e44f 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -197,16 +197,54 @@ def dump_logs( print(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/products/{product_type}") +@app.get("/api/products/{product_id}") +async def get_product_by_id(product_id: str): + """fetch single product by id from either hotel_products or airline_products""" + try: + supabase = get_supabase() + + # try hotel_products first + response = supabase.table('hotel_products').select('*').eq('id', product_id).execute() + if response.data and len(response.data) > 0: + return {"success": True, "data": response.data[0]} + + # try airline_products + response = supabase.table('airline_products').select('*').eq('id', product_id).execute() + if response.data and len(response.data) > 0: + return {"success": True, "data": response.data[0]} + + raise HTTPException(status_code=404, detail="Product not found") + + except HTTPException: + raise + except Exception as e: + import traceback + print(f"[PRODUCT_BY_ID_ERROR] {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/products/type/{product_type}") async def get_products( product_type: str, - dateIndex: Optional[int] = None + dateIndex: Optional[int] = None, + origin: Optional[str] = None, + destination: Optional[str] = None, + tripType: Optional[str] = None, + adults: Optional[int] = None, + children: Optional[int] = None, + infants: Optional[int] = None, + rooms: Optional[int] = None ): """fetch products from supabase based on type (hotel or airline) params: product_type: either 'hotel' or 'airline' dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday) + origin: (airline) departure airport code + destination: (airline/hotel) arrival airport or hotel location + tripType: (airline) roundtrip, oneway, multicity + adults, children, infants: passenger counts + rooms: (hotel) number of rooms """ if product_type not in ['hotel', 'airline']: raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'") @@ -222,8 +260,60 @@ async def get_products( query = query.eq('date_index', dateIndex) response = query.execute() + results = response.data - return {"success": True, "count": len(response.data), "data": response.data} + # apply in-memory filters based on metadata for airline products + if product_type == 'airline' and results: + filtered = [] + for product in results: + metadata = product.get('metadata', {}) + + # filter by origin airport + if origin: + dep = metadata.get('departure', {}) + if dep.get('airport') != origin: + continue + + # filter by destination airport + if destination: + arr = metadata.get('arrival', {}) + if arr.get('airport') != destination: + continue + + # passenger count validation (ensure total capacity) + if adults is not None or children is not None or infants is not None: + total_pax = (adults or 0) + (children or 0) + (infants or 0) + avail = product.get('availability', 0) + if avail < total_pax: + continue + + filtered.append(product) + + results = filtered + + # apply in-memory filters for hotel products + elif product_type == 'hotel' and results: + filtered = [] + for product in results: + metadata = product.get('metadata', {}) + + # filter by occupancy capacity + if adults is not None: + max_occ = metadata.get('max_occupancy', 2) + if max_occ < adults: + continue + + # filter by room availability + if rooms is not None: + avail = product.get('availability', 0) + if avail < rooms: + continue + + filtered.append(product) + + results = filtered + + return {"success": True, "count": len(results), "data": results} except Exception as e: import traceback diff --git a/web/src/app/airline/products/[id]/page.tsx b/web/src/app/airline/products/[id]/page.tsx new file mode 100644 index 0000000..001a0fe --- /dev/null +++ b/web/src/app/airline/products/[id]/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +import AirlineDetails from '@/components/feats/airline/AirlineDetails'; +import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +export default function AirlineProductPage() { + const params = useParams(); + const router = useRouter(); + const { addItem } = useCart(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [added, setAdded] = useState(false); + + const productId = params.id as string; + + useEffect(() => { + const fetchProduct = async () => { + try { + const res = await fetch(`/api/products/${productId}`); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = transformProduct(json.data as AirlineProduct); + setProduct(transformed); + + // fire learn_more_about_item event when product loads + dispatchInteraction('learn_more_about_item', productId, { + type: 'airline', + dateIndex: transformed.dateIndex, + flightType: transformed.flightType, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load product'); + console.error('[FETCH_FLIGHT_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchProduct(); + }, [productId]); + + const handleAddToCart = () => { + if (!product) return; + + addItem({ + id: productId, + type: 'airline', + name: product.flightType, + price: product.basePrice, + metadata: { + departure: product.departure, + arrival: product.arrival, + duration: product.duration, + cabinClass: product.cabinClass, + }, + dateIndex: product.dateIndex, + }); + + dispatchInteraction('add_item_to_cart', productId, { + type: 'airline', + price: product.basePrice, + }); + + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + <> + +
+ {loading &&
Loading flight details...
} + {error &&
{error}
} + + {!loading && !error && product && ( + <> + + + + + )} +
+ + ); +} diff --git a/web/src/app/airline/products/page.tsx b/web/src/app/airline/products/page.tsx index ce86345..769ddf0 100644 --- a/web/src/app/airline/products/page.tsx +++ b/web/src/app/airline/products/page.tsx @@ -18,8 +18,12 @@ function FlightsList() { const url = new URL('/api/products', window.location.origin); url.searchParams.set('type', 'airline'); - const dateIndex = searchParams.get('dateIndex'); - if (dateIndex) url.searchParams.set('dateIndex', dateIndex); + // forward all relevant search params to the API + const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants']; + params.forEach(param => { + const val = searchParams.get(param); + if (val) url.searchParams.set(param, val); + }); const res = await fetch(url.toString()); if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); diff --git a/web/src/app/api/products/[id]/route.ts b/web/src/app/api/products/[id]/route.ts new file mode 100644 index 0000000..f942424 --- /dev/null +++ b/web/src/app/api/products/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + if (!id) { + return NextResponse.json( + { error: 'product id is required' }, + { status: 400 } + ); + } + + try { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000'; + const url = new URL(`${backendUrl}/api/products/${id}`); + + const res = await fetch(url.toString()); + + if (!res.ok) { + throw new Error(`Backend returned ${res.status}`); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[PRODUCT_DETAIL_ERROR]', error); + return NextResponse.json( + { error: 'Failed to fetch product details' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/products/route.ts b/web/src/app/api/products/route.ts index 2cf5ba2..c51fdf2 100644 --- a/web/src/app/api/products/route.ts +++ b/web/src/app/api/products/route.ts @@ -13,11 +13,14 @@ export async function GET(req: NextRequest) { try { const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000'; - const url = new URL(`${backendUrl}/api/products/${type}`); + const url = new URL(`${backendUrl}/api/products/type/${type}`); - // forward date index offset to backend - const dateIndex = searchParams.get('dateIndex'); - if (dateIndex) url.searchParams.set('dateIndex', dateIndex); + // forward all query params to backend (excluding 'type') + searchParams.forEach((value, key) => { + if (key !== 'type') { + url.searchParams.set(key, value); + } + }); const res = await fetch(url.toString()); diff --git a/web/src/app/cart/page.tsx b/web/src/app/cart/page.tsx new file mode 100644 index 0000000..79a370b --- /dev/null +++ b/web/src/app/cart/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +export default function CartPage() { + const { items, removeItem, clearCart, itemCount } = useCart(); + + const handleRemove = (id: string, type: string) => { + removeItem(id); + dispatchInteraction('remove_item', id, { type }); + }; + let itemTypes = Array.from(new Set(items.map(item => item.type)))[0] || 'items'; + + + const total = items.reduce((sum, item) => sum + item.price, 0); + + return ( + <> + +
+
+

Shopping Cart

+ {itemCount > 0 && ( + + )} +
+ + {itemCount === 0 ? ( +
+

Your cart is empty

+ Browse our selection +
+ ) : ( + <> +
+ {items.map(item => ( +
+
+
+ + {item.type} + +

{item.name}

+
+ + {item.type === 'hotel' && ( +
+

{item.metadata.roomType as string}

+

{item.metadata.checkIn as string} - {item.metadata.checkOut as string}

+

{item.metadata.nights} night{(item.metadata.nights as number) > 1 ? 's' : ''}

+
+ )} + + {item.type === 'airline' && ( +
+

{item.metadata.cabinClass as string} Class

+

{(item.metadata.departure as any).airport} → {(item.metadata.arrival as any).airport}

+

Duration: {item.metadata.duration as string}

+
+ )} +
+ +
+

${item.price}

+ +
+
+ ))} +
+ +
+
+ Total + ${total.toFixed(2)} +
+ +
+ + )} +
+ + ); +} diff --git a/web/src/app/hotel/products/[id]/page.tsx b/web/src/app/hotel/products/[id]/page.tsx new file mode 100644 index 0000000..d39b504 --- /dev/null +++ b/web/src/app/hotel/products/[id]/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +import HotelDetails from '@/components/feats/hotel/HotelDetails'; +import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +export default function HotelProductPage() { + const params = useParams(); + const router = useRouter(); + const { addItem } = useCart(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [added, setAdded] = useState(false); + + const productId = params.id as string; + + useEffect(() => { + const fetchProduct = async () => { + try { + const res = await fetch(`/api/products/${productId}`); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = transformProduct(json.data as HotelProduct); + setProduct(transformed); + + // fire learn_more_about_item event when product loads + dispatchInteraction('learn_more_about_item', productId, { + type: 'hotel', + dateIndex: transformed.dateIndex, + roomType: transformed.roomType, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load product'); + console.error('[FETCH_HOTEL_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchProduct(); + }, [productId]); + + const handleAddToCart = () => { + if (!product) return; + + addItem({ + id: productId, + type: 'hotel', + name: product.name, + price: product.pricePerNight, + metadata: { + roomType: product.roomType, + nights: product.nights, + checkIn: product.checkIn, + checkOut: product.checkOut, + }, + dateIndex: product.dateIndex, + }); + + dispatchInteraction('add_item_to_cart', productId, { + type: 'hotel', + price: product.pricePerNight, + }); + + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + <> + +
+ {loading &&
Loading hotel details...
} + {error &&
{error}
} + + {!loading && !error && product && ( + <> + + + + + )} +
+ + ); +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx index bc33b3e..a11976c 100644 --- a/web/src/app/hotel/products/page.tsx +++ b/web/src/app/hotel/products/page.tsx @@ -18,8 +18,12 @@ function RoomsList() { const url = new URL('/api/products', window.location.origin); url.searchParams.set('type', 'hotel'); - const dateIndex = searchParams.get('dateIndex'); - if (dateIndex) url.searchParams.set('dateIndex', dateIndex); + // forward all relevant search params to the API + const params = ['dateIndex', 'destination', 'adults', 'rooms']; + params.forEach(param => { + const val = searchParams.get(param); + if (val) url.searchParams.set(param, val); + }); const res = await fetch(url.toString()); if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 2cef36e..e9f9b63 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TrackingProvider } from "@/components/TrackingProvider"; +import { CartProvider } from "@/contexts/CartContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,7 +29,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx index a43dfae..a651312 100644 --- a/web/src/components/feats/airline/AirlineCard.tsx +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -32,6 +32,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) { price: flight.basePrice, dateIndex: flight.dateIndex, }); + window.location.href = `/airline/products/${flight.id}`; }; return ( diff --git a/web/src/components/feats/airline/AirlineDetails.tsx b/web/src/components/feats/airline/AirlineDetails.tsx new file mode 100644 index 0000000..295c5a7 --- /dev/null +++ b/web/src/components/feats/airline/AirlineDetails.tsx @@ -0,0 +1,83 @@ +'use client'; + +import type { Flight } from '@/lib/airline-utils'; + +interface AirlineDetailsProps { + product: Flight; + onAddToCart: () => void; + addedToCart: boolean; +} + +export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) { + return ( +
+
+ Flight Image +
+ +
+

{product.flightType}

+

{product.cabinClass} Class

+
+ +
+
+

Departure

+

{product.departure.time}

+

{product.departure.airport}

+
+
+

Arrival

+

{product.arrival.time}

+

{product.arrival.airport}

+
+
+ +
+
+

Duration

+

{product.duration}

+
+
+

Stops

+

+ {product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`} +

+
+
+

Availability

+

{product.availability} seats

+
+
+ +
+

Fare Details

+

{product.fareRule}

+ {product.refundable && ( +
+

Refundable fare

+
+ )} +
+ +
+
+
+

Total price

+

${product.basePrice}

+
+
+
+ +
+ +
+
+ ); +} diff --git a/web/src/components/feats/airline/AirlineHero.tsx b/web/src/components/feats/airline/AirlineHero.tsx index cca2e45..ec2d47e 100644 --- a/web/src/components/feats/airline/AirlineHero.tsx +++ b/web/src/components/feats/airline/AirlineHero.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui'; +import { dateToDaysFromToday } from '@/lib/airline-utils'; type TripType = 'roundtrip' | 'oneway' | 'multicity'; @@ -19,6 +21,7 @@ const LocationIcon = () => ( ); export default function AirlineHero() { + const router = useRouter(); const [tripType, setTripType] = useState('roundtrip'); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); @@ -28,7 +31,23 @@ export default function AirlineHero() { const handleSearch = (e: FormEvent) => { e.preventDefault(); - console.log({ tripType, origin, destination, departDate, returnDate, passengers }); + const params = new URLSearchParams(); + + if (departDate) { + const daysOffset = dateToDaysFromToday(departDate); + params.set('dateIndex', daysOffset.toString()); + } + + if (origin) params.set('origin', origin); + if (destination) params.set('destination', destination); + if (tripType !== 'roundtrip') params.set('tripType', tripType); + if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate); + + params.set('adults', passengers.adults.toString()); + params.set('children', passengers.children.toString()); + params.set('infants', passengers.infants.toString()); + + router.push(`/airline/products?${params.toString()}`); }; const totalPax = passengers.adults + passengers.children + passengers.infants; diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx index f5c3f84..fc961a1 100644 --- a/web/src/components/feats/hotel/HotelCard.tsx +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -44,6 +44,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) { nights: hotel.nights, dateIndex: hotel.dateIndex, }); + window.location.href = `/hotel/products/${hotel.id}`; }; return ( diff --git a/web/src/components/feats/hotel/HotelDetails.tsx b/web/src/components/feats/hotel/HotelDetails.tsx new file mode 100644 index 0000000..f270edf --- /dev/null +++ b/web/src/components/feats/hotel/HotelDetails.tsx @@ -0,0 +1,74 @@ +'use client'; + +import type { Hotel } from '@/lib/hotel-utils'; + +interface HotelDetailsProps { + product: Hotel; + onAddToCart: () => void; + addedToCart: boolean; +} + +export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) { + return ( +
+
+ Hotel Image +
+ +
+

{product.name}

+

{product.roomType}

+
+ +
+
+

Check-in

+

{product.checkIn}

+
+
+

Check-out

+

{product.checkOut}

+
+
+ +
+

Amenities

+
+ {product.amenities.map(a => ( + + {a} + + ))} +
+
+ + {product.refundable && ( +
+

Free cancellation available

+
+ )} + +
+
+
+

Price per night

+

${product.pricePerNight}

+

+ Total for {product.nights} night{product.nights > 1 ? 's' : ''}: ${product.pricePerNight * product.nights} +

+
+
+
+ +
+ +
+
+ ); +} diff --git a/web/src/contexts/CartContext.tsx b/web/src/contexts/CartContext.tsx new file mode 100644 index 0000000..3830c8b --- /dev/null +++ b/web/src/contexts/CartContext.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +export interface CartItem { + id: string; + type: 'hotel' | 'airline'; + name: string; + price: number; + metadata: Record; + dateIndex: number; +} + +interface CartContextType { + items: CartItem[]; + addItem: (item: CartItem) => void; + removeItem: (id: string) => void; + clearCart: () => void; + itemCount: number; +} + +const CartContext = createContext(undefined); + +const CART_KEY = 'phantom_cart'; + +export const CartProvider = ({ children }: { children: ReactNode }) => { + const [items, setItems] = useState([]); + const [loaded, setLoaded] = useState(false); + + // load cart from sessionStorage on mount + useEffect(() => { + const stored = sessionStorage.getItem(CART_KEY); + if (stored) { + try { + setItems(JSON.parse(stored)); + } catch (e) { + console.error('[CART_LOAD]', e); + } + } + setLoaded(true); + }, []); + + // persist to sessionStorage whenever cart changes + useEffect(() => { + if (!loaded) return; + sessionStorage.setItem(CART_KEY, JSON.stringify(items)); + }, [items, loaded]); + + const addItem = (item: CartItem) => { + setItems(prev => { + // prevent duplicates + if (prev.find(i => i.id === item.id)) return prev; + return [...prev, item]; + }); + }; + + const removeItem = (id: string) => { + setItems(prev => prev.filter(i => i.id !== id)); + }; + + const clearCart = () => { + setItems([]); + }; + + return ( + + {children} + + ); +}; + +export const useCart = () => { + const ctx = useContext(CartContext); + if (!ctx) throw new Error('useCart must be used within CartProvider'); + return ctx; +}; diff --git a/web/src/lib/product-utils.ts b/web/src/lib/product-utils.ts new file mode 100644 index 0000000..2b33001 --- /dev/null +++ b/web/src/lib/product-utils.ts @@ -0,0 +1,25 @@ +import { HotelProduct, Hotel, transformProduct as transformHotel } from './hotel-utils'; +import { AirlineProduct, Flight, transformProduct as transformFlight } from './airline-utils'; + +export type Product = Hotel | Flight; +export type ProductRaw = HotelProduct | AirlineProduct; + +export const isHotelProduct = (p: ProductRaw): p is HotelProduct => { + return 'room_type' in p; +}; + +export const isAirlineProduct = (p: ProductRaw): p is AirlineProduct => { + return 'flight_type' in p; +}; + +export const transformProduct = (p: ProductRaw): Product => { + if (isHotelProduct(p)) { + return transformHotel(p); + } + return transformFlight(p); +}; + +export const getProductType = (p: Product): 'hotel' | 'airline' => { + if ('roomType' in p) return 'hotel'; + return 'airline'; +}; diff --git a/web/src/proxy.ts b/web/src/proxy.ts index e602618..7e47089 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -11,6 +11,7 @@ export function proxy(req: NextRequest) { pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.startsWith('/start-task') || + pathname.startsWith('/cart') || pathname.includes('.') // TODO: add robots.txt and sitemap.xml if needed here ) {