mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
supabase product proxy and rendering
This commit is contained in:
@@ -11,6 +11,7 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
|||||||
from kafka.admin import NewTopic
|
from kafka.admin import NewTopic
|
||||||
from kafka.errors import TopicAlreadyExistsError
|
from kafka.errors import TopicAlreadyExistsError
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from supabase import create_client, Client
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -18,6 +19,19 @@ app = FastAPI()
|
|||||||
# kafka producer - lazy init
|
# kafka producer - lazy init
|
||||||
_producer: Optional[KafkaProducer] = None
|
_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:
|
def get_producer() -> KafkaProducer:
|
||||||
global _producer
|
global _producer
|
||||||
if _producer is None:
|
if _producer is None:
|
||||||
@@ -183,6 +197,40 @@ 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}")
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0
|
|||||||
kafka-python==2.0.2
|
kafka-python==2.0.2
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
supabase==2.9.1
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- 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:
|
depends_on:
|
||||||
- kafka
|
- kafka
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
37
web/src/app/api/products/route.ts
Normal file
37
web/src/app/api/products/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,65 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||||
|
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
||||||
|
|
||||||
interface Hotel {
|
function RoomsList() {
|
||||||
id: string;
|
const searchParams = useSearchParams();
|
||||||
name: string;
|
const [rooms, setRooms] = useState<Hotel[]>([]);
|
||||||
roomType: string;
|
const [loading, setLoading] = useState(true);
|
||||||
checkIn: string;
|
const [error, setError] = useState<string | null>(null);
|
||||||
checkOut: string;
|
|
||||||
amenities: string[];
|
useEffect(() => {
|
||||||
refundable: boolean;
|
const fetchRooms = async () => {
|
||||||
pricePerNight: number;
|
try {
|
||||||
nights: number;
|
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 (
|
||||||
|
<>
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Available Rooms</h1>
|
||||||
|
{loading && <div className="text-center py-8">Loading...</div>}
|
||||||
|
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<HotelCard key={r.id} hotel={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export default function HotelProducts() {
|
||||||
const hotels = genRandomHotels();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||||
<div className="space-y-4">
|
<RoomsList />
|
||||||
{hotels.map((h) => (
|
</Suspense>
|
||||||
<HotelCard key={h.id} hotel={h} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
||||||
|
|
||||||
const LocationIcon = () => (
|
const LocationIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -11,14 +13,25 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function HotelHero() {
|
export default function HotelHero() {
|
||||||
|
const router = useRouter();
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [checkIn, setCheckIn] = useState('');
|
const [checkIn, setCheckIn] = useState('');
|
||||||
const [checkOut, setCheckOut] = useState('');
|
|
||||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
@@ -26,16 +39,16 @@ export default function HotelHero() {
|
|||||||
<div className="w-full max-w-4xl px-4">
|
<div className="w-full max-w-4xl px-4">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
Find your perfect stay
|
Find your perfect room
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg">
|
<p className="text-lg">
|
||||||
Search hotels, compare prices, and book with confidence
|
Search rooms, compare prices, and book with confidence
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="search-form">
|
<form onSubmit={handleSearch} className="search-form">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div className="sm:col-span-2">
|
<div>
|
||||||
<Label htmlFor="destination">Where to?</Label>
|
<Label htmlFor="destination">Where to?</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -49,7 +62,7 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkIn">Check-in</Label>
|
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="checkIn"
|
id="checkIn"
|
||||||
value={checkIn}
|
value={checkIn}
|
||||||
@@ -59,43 +72,27 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkOut">Check-out</Label>
|
<Label htmlFor="guests">Guests</Label>
|
||||||
<DateInput
|
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
||||||
id="checkOut"
|
|
||||||
value={checkOut}
|
|
||||||
onChange={(e) => setCheckOut(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-4">
|
|
||||||
<Label htmlFor="guests">Guests & Rooms</Label>
|
|
||||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
|
||||||
<DropdownCounter
|
<DropdownCounter
|
||||||
label="Adults"
|
label="Adults"
|
||||||
value={guests.adults}
|
value={guests.adults}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||||
/>
|
/>
|
||||||
<DropdownCounter
|
|
||||||
label="Rooms"
|
|
||||||
value={guests.rooms}
|
|
||||||
min={1}
|
|
||||||
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-4">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<Button type="submit" fullWidth>
|
<Button type="submit" fullWidth>
|
||||||
Search Hotels
|
Search Rooms
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
69
web/src/lib/hotel-utils.ts
Normal file
69
web/src/lib/hotel-utils.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user