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())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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(
|
async def get_products(
|
||||||
product_type: str,
|
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)
|
"""fetch products from supabase based on type (hotel or airline)
|
||||||
|
|
||||||
params:
|
params:
|
||||||
product_type: either 'hotel' or 'airline'
|
product_type: either 'hotel' or 'airline'
|
||||||
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
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']:
|
if product_type not in ['hotel', 'airline']:
|
||||||
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or '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)
|
query = query.eq('date_index', dateIndex)
|
||||||
|
|
||||||
response = query.execute()
|
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:
|
except Exception as e:
|
||||||
import traceback
|
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);
|
const url = new URL('/api/products', window.location.origin);
|
||||||
url.searchParams.set('type', 'airline');
|
url.searchParams.set('type', 'airline');
|
||||||
|
|
||||||
const dateIndex = searchParams.get('dateIndex');
|
// forward all relevant search params to the API
|
||||||
if (dateIndex) url.searchParams.set('dateIndex', dateIndex);
|
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());
|
const res = await fetch(url.toString());
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
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 {
|
try {
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
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
|
// forward all query params to backend (excluding 'type')
|
||||||
const dateIndex = searchParams.get('dateIndex');
|
searchParams.forEach((value, key) => {
|
||||||
if (dateIndex) url.searchParams.set('dateIndex', dateIndex);
|
if (key !== 'type') {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
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);
|
const url = new URL('/api/products', window.location.origin);
|
||||||
url.searchParams.set('type', 'hotel');
|
url.searchParams.set('type', 'hotel');
|
||||||
|
|
||||||
const dateIndex = searchParams.get('dateIndex');
|
// forward all relevant search params to the API
|
||||||
if (dateIndex) url.searchParams.set('dateIndex', dateIndex);
|
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());
|
const res = await fetch(url.toString());
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
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 { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { TrackingProvider } from "@/components/TrackingProvider";
|
import { TrackingProvider } from "@/components/TrackingProvider";
|
||||||
|
import { CartProvider } from "@/contexts/CartContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -28,7 +29,9 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<CartProvider>
|
||||||
<TrackingProvider>{children}</TrackingProvider>
|
<TrackingProvider>{children}</TrackingProvider>
|
||||||
|
</CartProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
price: flight.basePrice,
|
price: flight.basePrice,
|
||||||
dateIndex: flight.dateIndex,
|
dateIndex: flight.dateIndex,
|
||||||
});
|
});
|
||||||
|
window.location.href = `/airline/products/${flight.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function AirlineHero() {
|
export default function AirlineHero() {
|
||||||
|
const router = useRouter();
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
@@ -28,7 +31,23 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
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;
|
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
nights: hotel.nights,
|
nights: hotel.nights,
|
||||||
dateIndex: hotel.dateIndex,
|
dateIndex: hotel.dateIndex,
|
||||||
});
|
});
|
||||||
|
window.location.href = `/hotel/products/${hotel.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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('/_next') ||
|
||||||
pathname.startsWith('/static') ||
|
pathname.startsWith('/static') ||
|
||||||
pathname.startsWith('/start-task') ||
|
pathname.startsWith('/start-task') ||
|
||||||
|
pathname.startsWith('/cart') ||
|
||||||
pathname.includes('.')
|
pathname.includes('.')
|
||||||
// TODO: add robots.txt and sitemap.xml if needed here
|
// TODO: add robots.txt and sitemap.xml if needed here
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user