From 3cb1201acc6d20f739f573614f13222b65755aad Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 19 Nov 2025 09:05:48 +0100 Subject: [PATCH] supabase product proxy and rendering --- backend/server/app.py | 48 ++++++++ backend/server/requirements.txt | 1 + docker-compose.yml | 3 + web/src/app/api/products/route.ts | 37 +++++++ web/src/app/hotel/products/page.tsx | 109 +++++++++---------- web/src/components/feats/hotel/HotelHero.tsx | 53 +++++---- web/src/lib/hotel-utils.ts | 69 ++++++++++++ 7 files changed, 233 insertions(+), 87 deletions(-) create mode 100644 web/src/app/api/products/route.ts create mode 100644 web/src/lib/hotel-utils.ts diff --git a/backend/server/app.py b/backend/server/app.py index 4093c7d..8fa0648 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -11,6 +11,7 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer from kafka.admin import NewTopic from kafka.errors import TopicAlreadyExistsError from dotenv import load_dotenv +from supabase import create_client, Client load_dotenv() app = FastAPI() @@ -18,6 +19,19 @@ app = FastAPI() # kafka producer - lazy init _producer: Optional[KafkaProducer] = None +# supabase client - lazy init +_supabase: Optional[Client] = None + +def get_supabase() -> Client: + global _supabase + if _supabase is None: + url = os.getenv('NEXT_PUBLIC_SUPABASE_URL') + key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY') + if not url or not key: + raise ValueError("Supabase credentials not configured") + _supabase = create_client(url, key) + return _supabase + def get_producer() -> KafkaProducer: global _producer if _producer is None: @@ -183,6 +197,40 @@ def dump_logs( print(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/products/{product_type}") +async def get_products( + product_type: str, + dateIndex: 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) + """ + if product_type not in ['hotel', 'airline']: + raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'") + + try: + supabase = get_supabase() + table = f'{product_type}_products' + + query = supabase.table(table).select('*') + + # filter by exact date_index if provided + if dateIndex is not None: + query = query.eq('date_index', dateIndex) + + response = query.execute() + + return {"success": True, "count": len(response.data), "data": response.data} + + except Exception as e: + import traceback + print(f"[PRODUCTS_ERROR] {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + if __name__ == "__main__": diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index d9113ed..6a49ae4 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0 kafka-python==2.0.2 pydantic==2.5.0 python-dotenv==1.0.0 +supabase==2.9.1 diff --git a/docker-compose.yml b/docker-compose.yml index 49223a2..01da852 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: environment: - KAFKA_HOST=kafka - KAFKA_PORT=29092 + - BACKEND_PORT=5000 + - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} + - NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} depends_on: - kafka restart: unless-stopped diff --git a/web/src/app/api/products/route.ts b/web/src/app/api/products/route.ts new file mode 100644 index 0000000..2cf5ba2 --- /dev/null +++ b/web/src/app/api/products/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const type = searchParams.get('type'); + + if (!type || !['hotel', 'airline'].includes(type)) { + return NextResponse.json( + { error: 'type parameter must be "hotel" or "airline"' }, + { status: 400 } + ); + } + + try { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000'; + const url = new URL(`${backendUrl}/api/products/${type}`); + + // forward date index offset to backend + const dateIndex = searchParams.get('dateIndex'); + if (dateIndex) url.searchParams.set('dateIndex', dateIndex); + + 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('[PRODUCTS_PROXY_ERROR]', error); + return NextResponse.json( + { error: 'Failed to fetch products' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx index ece120b..bc33b3e 100644 --- a/web/src/app/hotel/products/page.tsx +++ b/web/src/app/hotel/products/page.tsx @@ -1,74 +1,65 @@ 'use client'; +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Navigation } from '@/components/ui'; import HotelCard from '@/components/feats/hotel/HotelCard'; +import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils'; -interface Hotel { - id: string; - name: string; - roomType: string; - checkIn: string; - checkOut: string; - amenities: string[]; - refundable: boolean; - pricePerNight: number; - nights: number; +function RoomsList() { + const searchParams = useSearchParams(); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRooms = async () => { + try { + 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); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = json.data.map((p: HotelProduct) => transformProduct(p)); + setRooms(transformed); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load products'); + console.error('[FETCH_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchRooms(); + }, [searchParams]); + + return ( + <> +

Available Rooms

+ {loading &&
Loading...
} + {error &&
{error}
} + {!loading && !error && ( +
+ {rooms.map((r) => ( + + ))} +
+ )} + + ); } -const genRandomHotels = (): Hotel[] => { - const names = [ - 'Grand Plaza Hotel', - 'Seaside Resort', - 'Downtown Suites', - 'Mountain View Lodge', - 'City Center Inn', - 'Luxury Beach Resort', - 'Urban Boutique Hotel', - 'Garden View Hotel', - ]; - const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room']; - const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa']; - - return Array.from({ length: 10 }, (_, i) => { - const nights = Math.floor(Math.random() * 5) + 1; - const basePrice = Math.floor(80 + Math.random() * 220); - const selectedAmenities = amenities - .sort(() => Math.random() - 0.5) - .slice(0, Math.floor(Math.random() * 3) + 2); - - const today = new Date(); - const checkInDate = new Date(today); - checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10)); - const checkOutDate = new Date(checkInDate); - checkOutDate.setDate(checkInDate.getDate() + nights); - - return { - id: `htl-${i}`, - name: names[i % names.length], - roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)], - checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - amenities: selectedAmenities, - refundable: Math.random() > 0.5, - pricePerNight: basePrice, - nights, - }; - }); -}; - export default function HotelProducts() { - const hotels = genRandomHotels(); - return ( <>
-

Available Hotels

-
- {hotels.map((h) => ( - - ))} -
+ Loading...}> + +
); diff --git a/web/src/components/feats/hotel/HotelHero.tsx b/web/src/components/feats/hotel/HotelHero.tsx index 70bd595..19b58ef 100644 --- a/web/src/components/feats/hotel/HotelHero.tsx +++ b/web/src/components/feats/hotel/HotelHero.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui'; +import { dateToDaysFromToday } from '@/lib/hotel-utils'; const LocationIcon = () => ( @@ -11,14 +13,25 @@ const LocationIcon = () => ( ); export default function HotelHero() { + const router = useRouter(); const [destination, setDestination] = useState(''); const [checkIn, setCheckIn] = useState(''); - const [checkOut, setCheckOut] = useState(''); const [guests, setGuests] = useState({ adults: 2, rooms: 1 }); const handleSearch = (e: FormEvent) => { e.preventDefault(); - console.log({ destination, checkIn, checkOut, guests }); + const params = new URLSearchParams(); + + if (checkIn) { + const daysOffset = dateToDaysFromToday(checkIn); + params.set('dateIndex', daysOffset.toString()); + } + + if (destination) params.set('destination', destination); + params.set('adults', guests.adults.toString()); + params.set('rooms', guests.rooms.toString()); + + router.push(`/hotel/products?${params.toString()}`); }; return ( @@ -26,16 +39,16 @@ export default function HotelHero() {

- Find your perfect stay + Find your perfect room

- Search hotels, compare prices, and book with confidence + Search rooms, compare prices, and book with confidence

-
-
+
+
- +
- - setCheckOut(e.target.value)} - required - /> -
- -
- - + + setGuests({ ...guests, adults: v })} /> - setGuests({ ...guests, rooms: v })} - />
-
+
-

Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings

+

Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings

diff --git a/web/src/lib/hotel-utils.ts b/web/src/lib/hotel-utils.ts new file mode 100644 index 0000000..2771412 --- /dev/null +++ b/web/src/lib/hotel-utils.ts @@ -0,0 +1,69 @@ +export interface HotelProduct { + id: string; + room_type: string; + date_index: number; + metadata: { + amenities?: string[]; + total?: number; + image_url?: string; + base_price?: number; + name?: string; + refundable?: boolean; + }; + availability: number; +} + +export interface Hotel { + id: string; + name: string; + roomType: string; + checkIn: string; + checkOut: string; + amenities: string[]; + refundable: boolean; + pricePerNight: number; + nights: number; +} + +const EPOCH = new Date(0); + +export const transformProduct = (p: HotelProduct): Hotel => { + const { id, room_type, date_index, metadata } = p; + const checkIn = new Date(EPOCH.getTime() + date_index * 86400000); + const nights = 1; + const checkOut = new Date(checkIn.getTime() + nights * 86400000); + + return { + id, + name: metadata?.name || room_type, + roomType: room_type, + checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + amenities: metadata?.amenities || [], + refundable: metadata?.refundable || false, + pricePerNight: metadata?.base_price || 100, + nights, + }; +}; + +// convert date string to days from today +export const dateToDaysFromToday = (dateStr: string): number => { + const target = new Date(dateStr); + target.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / 86400000); +}; + +// convert date string to date_index (days since epoch) +export const dateToIndex = (dateStr: string): number => { + const d = new Date(dateStr); + return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000); +}; + +// get current date_index +export const todayIndex = (): number => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000); +};