mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
feat: huge push of product changes and item review with cart
This commit is contained in:
@@ -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
|
||||
|
||||
106
web/src/app/airline/products/[id]/page.tsx
Normal file
106
web/src/app/airline/products/[id]/page.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
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<Flight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{loading && <div className="text-center py-8">Loading flight details...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
|
||||
{!loading && !error && product && (
|
||||
<>
|
||||
<AirlineDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to flights
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
35
web/src/app/api/products/[id]/route.ts
Normal file
35
web/src/app/api/products/[id]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
110
web/src/app/cart/page.tsx
Normal file
110
web/src/app/cart/page.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
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 (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
||||
{itemCount > 0 && (
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Clear cart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{itemCount === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
||||
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4 mb-8">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex justify-between items-start p-4 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
{item.type}
|
||||
</span>
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
</div>
|
||||
|
||||
{item.type === 'hotel' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{item.metadata.roomType as string}</p>
|
||||
<p>{item.metadata.checkIn as string} - {item.metadata.checkOut as string}</p>
|
||||
<p>{item.metadata.nights} night{(item.metadata.nights as number) > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === 'airline' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{item.metadata.cabinClass as string} Class</p>
|
||||
<p>{(item.metadata.departure as any).airport} → {(item.metadata.arrival as any).airport}</p>
|
||||
<p>Duration: {item.metadata.duration as string}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p className="text-xl font-bold mb-2">${item.price}</p>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id, item.type)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-xl font-semibold">Total</span>
|
||||
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
web/src/app/hotel/products/[id]/page.tsx
Normal file
106
web/src/app/hotel/products/[id]/page.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
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<Hotel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{loading && <div className="text-center py-8">Loading hotel details...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
|
||||
{!loading && !error && product && (
|
||||
<>
|
||||
<HotelDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to rooms
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
<CartProvider>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
83
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
83
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-100 h-64 flex items-center justify-center rounded-lg">
|
||||
<span className="text-gray-400">Flight Image</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{product.flightType}</h1>
|
||||
<p className="text-lg text-gray-600">{product.cabinClass} Class</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Departure</h3>
|
||||
<p className="text-xl font-bold">{product.departure.time}</p>
|
||||
<p className="text-gray-600">{product.departure.airport}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Arrival</h3>
|
||||
<p className="text-xl font-bold">{product.arrival.time}</p>
|
||||
<p className="text-gray-600">{product.arrival.airport}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 py-4 border-y">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Duration</p>
|
||||
<p className="font-semibold">{product.duration}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Stops</p>
|
||||
<p className="font-semibold">
|
||||
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Availability</p>
|
||||
<p className="font-semibold">{product.availability} seats</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Fare Details</h3>
|
||||
<p className="text-sm text-gray-600 mb-1">{product.fareRule}</p>
|
||||
{product.refundable && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mt-2">
|
||||
<p className="text-green-800 text-sm font-medium">Refundable fare</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total price</p>
|
||||
<p className="text-3xl font-bold">${product.basePrice}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-green-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{addedToCart ? 'Added to Cart!' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TripType>('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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-100 h-64 flex items-center justify-center rounded-lg">
|
||||
<span className="text-gray-400">Hotel Image</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
|
||||
<p className="text-lg text-gray-600">{product.roomType}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Check-in</h3>
|
||||
<p>{product.checkIn}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Check-out</h3>
|
||||
<p>{product.checkOut}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.amenities.map(a => (
|
||||
<span key={a} className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.refundable && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-green-800 font-medium">Free cancellation available</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Price per night</p>
|
||||
<p className="text-3xl font-bold">${product.pricePerNight}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Total for {product.nights} night{product.nights > 1 ? 's' : ''}: ${product.pricePerNight * product.nights}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-green-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{addedToCart ? 'Added to Cart!' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
web/src/contexts/CartContext.tsx
Normal file
76
web/src/contexts/CartContext.tsx
Normal file
@@ -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<string, unknown>;
|
||||
dateIndex: number;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
const CART_KEY = 'phantom_cart';
|
||||
|
||||
export const CartProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
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 (
|
||||
<CartContext.Provider value={{ items, addItem, removeItem, clearCart, itemCount: items.length }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCart = () => {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
||||
return ctx;
|
||||
};
|
||||
25
web/src/lib/product-utils.ts
Normal file
25
web/src/lib/product-utils.ts
Normal file
@@ -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';
|
||||
};
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user