mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
6 catalog data and mode mappers (#25)
* supabase product proxy and rendering * minor pipeline refactor * refactoring and demand estimation * trackion of date index searching * fixing changes of imports * data seeding * chore: airline basic refactor * feat: huge push of product changes and item review with cart * refactored design * chore: moving route elsewhere and align * fix: build of web/ * chore: fixing paper build * fixing chars
This commit is contained in:
committed by
GitHub
parent
894ce87a5d
commit
8b76d24ade
@@ -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,130 @@ def dump_logs(
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@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,
|
||||
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'")
|
||||
|
||||
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()
|
||||
results = 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
|
||||
print(f"[PRODUCTS_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,957 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from kafka import KafkaConsumer\n",
|
||||
"import pandas as pd\n",
|
||||
"import json\n",
|
||||
"import numpy as np\n",
|
||||
"import os\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from IPython.display import display, SVG, Image\n",
|
||||
"load_dotenv()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||
"RangeIndex: 73 entries, 0 to 72\n",
|
||||
"Data columns (total 13 columns):\n",
|
||||
" # Column Non-Null Count Dtype \n",
|
||||
"--- ------ -------------- ----- \n",
|
||||
" 0 sessionId 73 non-null object \n",
|
||||
" 1 eventName 73 non-null object \n",
|
||||
" 2 page 73 non-null object \n",
|
||||
" 3 productId 67 non-null object \n",
|
||||
" 4 storeMode 73 non-null object \n",
|
||||
" 5 userAgent 73 non-null object \n",
|
||||
" 6 ts 73 non-null object \n",
|
||||
" 7 metadata_referrer 6 non-null object \n",
|
||||
" 8 metadata_roomType 45 non-null object \n",
|
||||
" 9 metadata_price 45 non-null float64\n",
|
||||
" 10 metadata_nights 45 non-null float64\n",
|
||||
" 11 metadata_elementText 22 non-null object \n",
|
||||
" 12 metadata_dwellTime 22 non-null float64\n",
|
||||
"dtypes: float64(3), object(10)\n",
|
||||
"memory usage: 7.5+ KB\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
||||
"topic = \"user-interactions\"\n",
|
||||
"consumer = KafkaConsumer(\n",
|
||||
" topic, \n",
|
||||
" enable_auto_commit=True,\n",
|
||||
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
||||
" auto_offset_reset='earliest', \n",
|
||||
" bootstrap_servers=['localhost:9092'])\n",
|
||||
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
||||
"df = []\n",
|
||||
"for m in messages.values():\n",
|
||||
" for i in m:\n",
|
||||
" df.append(i.value)\n",
|
||||
"df = pd.DataFrame(df)\n",
|
||||
"# explode metadata col json\n",
|
||||
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
||||
"df.info()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>sessionId</th>\n",
|
||||
" <th>eventName</th>\n",
|
||||
" <th>page</th>\n",
|
||||
" <th>productId</th>\n",
|
||||
" <th>storeMode</th>\n",
|
||||
" <th>userAgent</th>\n",
|
||||
" <th>ts</th>\n",
|
||||
" <th>metadata_referrer</th>\n",
|
||||
" <th>metadata_roomType</th>\n",
|
||||
" <th>metadata_price</th>\n",
|
||||
" <th>metadata_nights</th>\n",
|
||||
" <th>metadata_elementText</th>\n",
|
||||
" <th>metadata_dwellTime</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>d176d7c9-4027-4702-9e31-2a71395cdda0</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:23:46.270Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:00.291Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:07.769Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:15.010Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>269.0</td>\n",
|
||||
" <td>1.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>4</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.457Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>5</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.591Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>432</th>\n",
|
||||
" <td>214d9fad-9b00-40c3-bd0e-7739b6acd654</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1762448192425</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>1623.0</td>\n",
|
||||
" <td>493.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>6</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:21.483Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>7</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:22.646Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>8</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:25.889Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>35</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:53:59.993Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>36</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:10.705Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>223.0</td>\n",
|
||||
" <td>3.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>37</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:11.771Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>416.0</td>\n",
|
||||
" <td>397.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>38</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:29.772Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Standard Room</td>\n",
|
||||
" <td>267.0</td>\n",
|
||||
" <td>5.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>39</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:30.833Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Seaside Resort</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" sessionId eventName page \\\n",
|
||||
"0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n",
|
||||
"1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n",
|
||||
"2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n",
|
||||
"3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n",
|
||||
"4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n",
|
||||
"5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n",
|
||||
"8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n",
|
||||
"36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"\n",
|
||||
" productId storeMode userAgent \\\n",
|
||||
"0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"\n",
|
||||
" ts metadata_referrer metadata_roomType \\\n",
|
||||
"0 2025-11-14T13:23:46.270Z NaN \n",
|
||||
"1 2025-11-14T13:26:00.291Z NaN \n",
|
||||
"2 2025-11-14T13:26:07.769Z NaN \n",
|
||||
"3 2025-11-14T13:26:15.010Z NaN Premium Room \n",
|
||||
"4 2025-11-14T13:27:15.457Z NaN \n",
|
||||
"5 2025-11-14T13:27:15.591Z NaN Premium Room \n",
|
||||
"6 2025-11-14T13:27:21.483Z NaN Premium Room \n",
|
||||
"7 2025-11-14T13:27:22.646Z NaN NaN \n",
|
||||
"8 2025-11-14T13:27:25.889Z NaN Premium Room \n",
|
||||
"35 2025-11-14T13:53:59.993Z NaN \n",
|
||||
"36 2025-11-14T13:54:10.705Z NaN Premium Room \n",
|
||||
"37 2025-11-14T13:54:11.771Z NaN NaN \n",
|
||||
"38 2025-11-14T13:54:29.772Z NaN Standard Room \n",
|
||||
"39 2025-11-14T13:54:30.833Z NaN NaN \n",
|
||||
"\n",
|
||||
" metadata_price metadata_nights metadata_elementText metadata_dwellTime \n",
|
||||
"0 NaN NaN NaN NaN \n",
|
||||
"1 NaN NaN NaN NaN \n",
|
||||
"2 NaN NaN NaN NaN \n",
|
||||
"3 269.0 1.0 NaN NaN \n",
|
||||
"4 NaN NaN NaN NaN \n",
|
||||
"5 264.0 2.0 NaN NaN \n",
|
||||
"6 264.0 2.0 NaN NaN \n",
|
||||
"7 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"8 264.0 2.0 NaN NaN \n",
|
||||
"35 NaN NaN NaN NaN \n",
|
||||
"36 223.0 3.0 NaN NaN \n",
|
||||
"37 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"38 267.0 5.0 NaN NaN \n",
|
||||
"39 NaN NaN Seaside Resort 1200.0 "
|
||||
]
|
||||
},
|
||||
"execution_count": 12,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df.groupby('sessionId').head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['013fc334-4045-4d5a-8739-dd0a8766a63b',\n",
|
||||
" '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n",
|
||||
" 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n",
|
||||
" 'f0317a5d-e424-44e9-b784-c8f7291ffe31']"
|
||||
]
|
||||
},
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# map sessions to experiments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||
" df = df.dropna(subset=['eventName'])\n",
|
||||
" events = df['eventName'].tolist()\n",
|
||||
" labels = pd.Index(events).unique().tolist()\n",
|
||||
" idx = {e:i for i,e in enumerate(labels)}\n",
|
||||
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
||||
" for a, b in zip(events, events[1:]):\n",
|
||||
" M[idx[a], idx[b]] += 1\n",
|
||||
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
||||
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
||||
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
||||
" return P, labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
||||
"from graphviz import Digraph\n",
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def _as_prob_df(matrix, labels=None):\n",
|
||||
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
||||
" if isinstance(matrix, pd.DataFrame):\n",
|
||||
" # Ensure square and aligned\n",
|
||||
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
||||
" return matrix\n",
|
||||
" matrix = np.asarray(matrix, dtype=float)\n",
|
||||
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
||||
" if labels is None:\n",
|
||||
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
||||
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
||||
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
||||
"\n",
|
||||
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
||||
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
||||
" edges = []\n",
|
||||
" for src in P.index:\n",
|
||||
" for dst in P.columns:\n",
|
||||
" w = float(P.loc[src, dst])\n",
|
||||
" if w > threshold:\n",
|
||||
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
||||
" return edges\n",
|
||||
"\n",
|
||||
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
||||
" \"\"\"\n",
|
||||
" fname: output file stem (no extension)\n",
|
||||
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
||||
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
||||
" threshold: hide edges with weight <= threshold\n",
|
||||
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
||||
" view: open after rendering\n",
|
||||
" \"\"\"\n",
|
||||
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
||||
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
||||
"\n",
|
||||
" g = Digraph(format=fmt)\n",
|
||||
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
||||
" g.attr(\"node\", shape=\"circle\")\n",
|
||||
"\n",
|
||||
" # ensure isolated nodes appear\n",
|
||||
" for node in P.index:\n",
|
||||
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
||||
"\n",
|
||||
" for src, dst, label in edges:\n",
|
||||
" g.edge(src, dst, label=label)\n",
|
||||
"\n",
|
||||
" g.render(fname, view=view, cleanup=True)\n",
|
||||
" return g\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 17,
|
||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"013fc334-4045-4d5a-8739-dd0a8766a63b\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-235.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-235.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-235.83C113.69,-235.83 133.31,-235.83 152.25,-235.83\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-239.33 162.1,-235.83 152.1,-232.33 152.1,-239.33\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-239.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-302.59C217.1,-314.51 223.14,-322.84 232.88,-322.84 239.27,-322.84 244.07,-319.26 247.28,-313.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-314.62 250.52,-304.02 243.95,-312.33 250.57,-314.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-326.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.68</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.48,-250.14C307.03,-251.43 313.58,-252.69 319.89,-253.83 340.12,-257.51 362.05,-261.1 382.5,-264.27\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.77,-267.7 392.19,-265.76 382.83,-260.78 381.77,-267.7\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-263.17\" font-family=\"Times,serif\" font-size=\"14.00\">0.29</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M292.09,-199.63C316.79,-184.27 346.14,-166.02 373.44,-149.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.08,-152.15 381.72,-143.89 371.38,-146.2 375.08,-152.15\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-185.68\" font-family=\"Times,serif\" font-size=\"14.00\">0.04</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M399.53,-246.73C384.12,-240.88 367.42,-235.6 351.39,-232.58 339.13,-230.28 326.03,-229.26 313.19,-229.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"313.51,-225.54 303.51,-229.04 313.51,-232.54 313.51,-225.54\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-236.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f0779e818b0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[]\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"8pt\" height=\"8pt\"\n",
|
||||
" viewBox=\"0.00 0.00 8.00 8.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 4)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-4 4,-4 4,4 -4,4\"/>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800fac980>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n",
|
||||
" [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n",
|
||||
"238dc588-a7ab-4c0e-bccd-6abca5076c66\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-109.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-105.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-197.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-193.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M92.02,-130.47C112.32,-140.25 137.13,-152.2 160.18,-163.3\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"158.39,-166.32 168.92,-167.51 161.43,-160.02 158.39,-166.32\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-157.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-264.59C217.1,-276.51 223.14,-284.84 232.88,-284.84 239.27,-284.84 244.07,-281.26 247.28,-275.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-276.62 250.52,-266.02 243.95,-274.33 250.57,-276.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-288.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.19</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M289.6,-237.16C299.36,-242.77 309.67,-247.94 319.89,-251.83 339.45,-259.28 361.4,-264.43 382.1,-267.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.52,-271.43 391.95,-269.55 382.62,-264.52 381.52,-271.43\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-265.16\" font-family=\"Times,serif\" font-size=\"14.00\">0.38</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.22,-180.71C317.22,-175.46 335.24,-169.12 351.39,-161.83 358.97,-158.41 366.67,-154.57 374.29,-150.49\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.84,-153.63 382.92,-145.75 372.47,-147.5 375.84,-153.63\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-178.15\" font-family=\"Times,serif\" font-size=\"14.00\">0.44</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M398.52,-248.36C383.21,-242.16 366.82,-235.87 351.39,-230.58 338.42,-226.15 324.5,-221.86 310.94,-217.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"312.2,-214.65 301.62,-215.28 310.28,-221.39 312.2,-214.65\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-234.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->page_view -->\n",
|
||||
"<g id=\"edge6\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M369.13,-95.76C310.26,-97.17 232.59,-99.41 163.87,-102.58 145.72,-103.42 125.98,-104.58 108.06,-105.73\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"107.86,-102.24 98.1,-106.38 108.31,-109.22 107.86,-102.24\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-106.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.14</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->view_item_page -->\n",
|
||||
"<g id=\"edge7\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M372.68,-119.15C354.84,-125.32 336.5,-132.51 319.89,-140.58 312.9,-143.98 305.81,-147.87 298.86,-151.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"297.49,-148.71 290.78,-156.91 301.14,-154.69 297.49,-148.71\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-144.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.86</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1. 0. 0. ]\n",
|
||||
" [0. 0.1875 0.375 0.4375 ]\n",
|
||||
" [0. 1. 0. 0. ]\n",
|
||||
" [0.14285714 0.85714286 0. 0. ]]\n",
|
||||
"d176d7c9-4027-4702-9e31-2a71395cdda0\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"104pt\" height=\"104pt\"\n",
|
||||
" viewBox=\"0.00 0.00 104.00 104.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 100.37)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-100.37 100.37,-100.37 100.37,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-48.19\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-43.51\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.]]\n",
|
||||
"f0317a5d-e424-44e9-b784-c8f7291ffe31\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"310pt\" height=\"160pt\"\n",
|
||||
" viewBox=\"0.00 0.00 310.00 160.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 156.44)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-156.44 305.89,-156.44 305.89,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-69.01\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->page_view -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M33.03,-115.09C34.09,-126.6 39.14,-135.19 48.19,-135.19 53.98,-135.19 58.13,-131.66 60.65,-126.1\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"64.01,-127.11 62.98,-116.56 57.21,-125.45 64.01,-127.11\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-139.14\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-69.01\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-69.01C113.69,-69.01 133.31,-69.01 152.25,-69.01\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-72.51 162.1,-69.01 152.1,-65.51 152.1,-72.51\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-72.96\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800bf50f0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[5.0e-001 5.0e-001]\n",
|
||||
" [9.9e-324 1.5e-323]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def explore_session(session_id: str):\n",
|
||||
" subset = df[df['sessionId'] == session_id]\n",
|
||||
" print(session_id)\n",
|
||||
" P, labels = build_transition_prob_matrix(subset)\n",
|
||||
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
||||
" display(g)\n",
|
||||
" return P\n",
|
||||
"for session in sessions:\n",
|
||||
" print(explore_session(session))"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python (PHANTOM)",
|
||||
"language": "python",
|
||||
"name": "phantom"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
19
experiments/procesing/__init__.py
Normal file
19
experiments/procesing/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .extract import (
|
||||
KafkaDataFetcher,
|
||||
ExperimentJoiner,
|
||||
EventTitleAugmenter,
|
||||
)
|
||||
from .demand import DemandEstimator
|
||||
from .mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||
from .pipeline import etl_pipeline, pricing_pipeline
|
||||
|
||||
__all__ = [
|
||||
'KafkaDataFetcher',
|
||||
'ExperimentJoiner',
|
||||
'EventTitleAugmenter',
|
||||
'DemandEstimator',
|
||||
'SessionTransitionProbMatrixTransformer',
|
||||
'render_graph',
|
||||
'etl_pipeline',
|
||||
'pricing_pipeline',
|
||||
]
|
||||
39
experiments/procesing/demand.py
Normal file
39
experiments/procesing/demand.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from supabase import create_client, Client
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
class DemandEstimator(BaseEstimator, TransformerMixin):
|
||||
def __init__(self,
|
||||
store_mode:str='hotel',
|
||||
session_filter:str="",
|
||||
experiment_filter:str=""):
|
||||
self.store=store_mode
|
||||
self.session_filter=session_filter if len(session_filter)>0 else None
|
||||
self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None
|
||||
def fit(self, X):
|
||||
return self
|
||||
|
||||
def transform(self, interactions : pd.DataFrame):
|
||||
if interactions.empty:
|
||||
return pd.DataFrame(columns=["productId", "demand_score"])
|
||||
if self.session_filter:
|
||||
interactions = interactions[interactions['sessionId'] == self.session_filter]
|
||||
if self.experiment_filter:
|
||||
interactions = interactions[interactions['experimentId'] == self.experiment_filter]
|
||||
products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute()
|
||||
products = pd.DataFrame(products.data)
|
||||
unique_products = products['id'].unique()
|
||||
# TODO: improve demand score calculation rather than just counting interactions (use weights..)
|
||||
# while maintaining simplicity of a simple cross tab approach
|
||||
product_demand = pd.crosstab(interactions['productId'], "no_of_interactions")
|
||||
product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index()
|
||||
product_demand.columns = ['productId', 'demand_score']
|
||||
return product_demand
|
||||
@@ -15,8 +15,12 @@ N_PRICE_BUCKETS = 5
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
def get_data_from_kafka() -> pd.DataFrame:
|
||||
"""fetch all events from backend dump endpoint"""
|
||||
|
||||
class KafkaDataFetcher(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, X=None):
|
||||
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -29,10 +33,16 @@ def get_data_from_kafka() -> pd.DataFrame:
|
||||
if 'metadata' in df.columns:
|
||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
||||
df = df.dropna(subset=['eventName'])
|
||||
# remape dateIndex
|
||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||
return df
|
||||
|
||||
|
||||
def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame:
|
||||
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, df):
|
||||
if df.empty or 'experimentId' not in df.columns:
|
||||
return df
|
||||
|
||||
@@ -68,7 +78,11 @@ def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return df
|
||||
|
||||
|
||||
def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame:
|
||||
class EventTitleAugmenter(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, df):
|
||||
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
||||
# we want metadata schema to create product specific event names
|
||||
|
||||
@@ -96,25 +110,3 @@ def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame:
|
||||
)
|
||||
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
|
||||
return df
|
||||
|
||||
|
||||
def extract() -> pd.DataFrame:
|
||||
df = get_data_from_kafka()
|
||||
df = join_with_experiments(df)
|
||||
df = augment_event_titles(df)
|
||||
return df
|
||||
|
||||
|
||||
class DataExtractor(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, X=None):
|
||||
return extract()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
df = extract()
|
||||
print(df.head())
|
||||
print(df.tail())
|
||||
print(df.info())
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from extract import DataExtractor
|
||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||
|
||||
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter
|
||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||
from demand import DemandEstimator
|
||||
|
||||
|
||||
# exposable pipelines
|
||||
etl_pipeline = Pipeline([
|
||||
('kafka_fetch', KafkaDataFetcher()),
|
||||
('experiment_join', ExperimentJoiner()),
|
||||
('event_augment', EventTitleAugmenter()),
|
||||
])
|
||||
pricing_pipeline = Pipeline([
|
||||
('demand_estimation', DemandEstimator()),
|
||||
])
|
||||
|
||||
if __name__ == "__main__":
|
||||
steps = [
|
||||
('data_extraction', DataExtractor()),
|
||||
#('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)),
|
||||
]
|
||||
pipeline = Pipeline(steps)
|
||||
result = pipeline.fit_transform(None)
|
||||
print(result)
|
||||
print(result.info())
|
||||
processed_data = etl_pipeline.fit_transform(None)
|
||||
pricing = pricing_pipeline.fit_transform(processed_data)
|
||||
print(pricing)
|
||||
|
||||
125
experiments/seed_products.py
Normal file
125
experiments/seed_products.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from supabase import create_client, Client
|
||||
from tqdm import tqdm
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
||||
|
||||
if not SUPABASE_SERVICE_KEY:
|
||||
log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment")
|
||||
raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations")
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
||||
|
||||
DAYS = 14
|
||||
|
||||
# hotel room configurations
|
||||
ROOMS = {
|
||||
"Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4},
|
||||
"Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3},
|
||||
"Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2},
|
||||
"Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2},
|
||||
"Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2},
|
||||
"Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2},
|
||||
}
|
||||
|
||||
# flight configurations
|
||||
FLIGHTS = {
|
||||
"JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250},
|
||||
"JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850},
|
||||
"ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180},
|
||||
"SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420},
|
||||
"ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600},
|
||||
"LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120},
|
||||
"MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380},
|
||||
"DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160},
|
||||
"SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720},
|
||||
"LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850},
|
||||
}
|
||||
|
||||
def gen_hotel_products():
|
||||
"""generate hotel room products for next DAYS days"""
|
||||
data = []
|
||||
for day in range(DAYS):
|
||||
for room_type, rdata in ROOMS.items():
|
||||
data.append({
|
||||
'room_type': room_type,
|
||||
'date_index': day + 1,
|
||||
'metadata': rdata,
|
||||
'availability': random.randint(0, rdata['total'])
|
||||
})
|
||||
return data
|
||||
|
||||
def gen_airline_products():
|
||||
"""generate flight products for next DAYS days"""
|
||||
data = []
|
||||
for day in range(DAYS):
|
||||
for flight_type, fdata in FLIGHTS.items():
|
||||
data.append({
|
||||
'flight_type': flight_type,
|
||||
'date_index': day + 1,
|
||||
'metadata': fdata,
|
||||
'availability': random.randint(0, fdata['total'])
|
||||
})
|
||||
return data
|
||||
|
||||
def clear_table(table_name: str):
|
||||
"""clear all records from a table"""
|
||||
try:
|
||||
resp = supabase.table(table_name).select('id').execute()
|
||||
if resp.data:
|
||||
ids = [row['id'] for row in resp.data]
|
||||
chunk_size = 100
|
||||
for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"):
|
||||
chunk = ids[i:i+chunk_size]
|
||||
supabase.table(table_name).delete().in_('id', chunk).execute()
|
||||
log.info(f"Deleted {len(ids)} records from {table_name}")
|
||||
else:
|
||||
log.info(f"{table_name} already empty")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to clear {table_name}: {e}")
|
||||
raise
|
||||
|
||||
def seed_table(table_name: str, data: list[dict]):
|
||||
"""insert records into a table"""
|
||||
try:
|
||||
chunk_size = 100
|
||||
total = len(data)
|
||||
for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"):
|
||||
chunk = data[i:i+chunk_size]
|
||||
supabase.table(table_name).insert(chunk).execute()
|
||||
log.info(f"Inserted {total} records into {table_name}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to seed {table_name}: {e}")
|
||||
raise
|
||||
|
||||
def main():
|
||||
|
||||
log.info("Generating hotel products...")
|
||||
hotel_products = gen_hotel_products()
|
||||
log.info(f"Generated {len(hotel_products)} hotel products")
|
||||
|
||||
log.info("Generating airline products...")
|
||||
airline_products = gen_airline_products()
|
||||
log.info(f"Generated {len(airline_products)} airline products\n")
|
||||
|
||||
log.info("Clearing existing products...")
|
||||
clear_table('hotel_products')
|
||||
clear_table('airline_products')
|
||||
|
||||
log.info("Seeding products...")
|
||||
seed_table('hotel_products', hotel_products)
|
||||
seed_table('airline_products', airline_products)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,7 +21,10 @@ add_file() {
|
||||
# Add section header and code listing (no language-specific highlighting)
|
||||
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
||||
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
||||
cat "$filepath" >> "$OUTPUT_FILE"
|
||||
# Convert to ASCII: transliterate what's possible, drop the rest
|
||||
# LC_ALL=C forces ASCII locale for consistent behavior across environments
|
||||
LC_ALL=C iconv -f UTF-8 -t ASCII//TRANSLIT//IGNORE "$filepath" 2>/dev/null >> "$OUTPUT_FILE" || \
|
||||
LC_ALL=C tr -cd '\11\12\15\40-\176' < "$filepath" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to flights
|
||||
</button>
|
||||
<AirlineDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
function FlightsList() {
|
||||
const searchParams = useSearchParams();
|
||||
const [flights, setFlights] = useState<Flight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
useEffect(() => {
|
||||
const fetchFlights = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
url.searchParams.set('type', 'airline');
|
||||
|
||||
const genRandomFlights = (): Flight[] => {
|
||||
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
||||
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
||||
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
||||
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const depHour = Math.floor(Math.random() * 24);
|
||||
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
|
||||
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
|
||||
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
|
||||
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
|
||||
|
||||
const basePrice = Math.floor(
|
||||
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
|
||||
Math.random() * 300
|
||||
);
|
||||
|
||||
return {
|
||||
id: `flt-${i}`,
|
||||
departure: {
|
||||
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
arrival: {
|
||||
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
|
||||
stops,
|
||||
cabinClass: cabin,
|
||||
fareRule,
|
||||
refundable: Math.random() > 0.7,
|
||||
basePrice,
|
||||
};
|
||||
// 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);
|
||||
});
|
||||
};
|
||||
|
||||
export default function AirlineProducts() {
|
||||
const flights = genRandomFlights();
|
||||
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: AirlineProduct) => transformProduct(p));
|
||||
setFlights(transformed);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
console.error('[FETCH_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFlights();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</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">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AirlineProducts() {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||
<FlightsList />
|
||||
</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
web/src/app/api/products/route.ts
Normal file
40
web/src/app/api/products/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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/${type}`);
|
||||
|
||||
// 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());
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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>{String(item.metadata.roomType)}</p>
|
||||
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
||||
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === 'airline' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{String(item.metadata.cabinClass)} Class</p>
|
||||
<p>{String((item.metadata.departure as any)?.airport)} → {String((item.metadata.arrival as any)?.airport)}</p>
|
||||
<p>Duration: {String(item.metadata.duration)}</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 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to rooms
|
||||
</button>
|
||||
<HotelDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,69 @@
|
||||
'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<Hotel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
url.searchParams.set('type', 'hotel');
|
||||
|
||||
// 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}`);
|
||||
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() {
|
||||
const hotels = genRandomHotels();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||
<div className="space-y-4">
|
||||
{hotels.map((h) => (
|
||||
<HotelCard key={h.id} hotel={h} />
|
||||
))}
|
||||
</div>
|
||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||
<RoomsList />
|
||||
</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<CartProvider>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Flight } from '@/lib/airline-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
@@ -11,32 +12,17 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
const durationRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: flight.duration },
|
||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: 'price' },
|
||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -44,7 +30,9 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
cabinClass: flight.cabinClass,
|
||||
fareRule: flight.fareRule,
|
||||
price: flight.basePrice,
|
||||
dateIndex: flight.dateIndex,
|
||||
});
|
||||
window.location.href = `/airline/products/${flight.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'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="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section */}
|
||||
<div className="w-full lg:w-1/3 bg-gray-100 rounded-lg aspect-square flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Flight Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start border-b pb-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-1">{product.flightType}</h1>
|
||||
<p className="text-lg text-gray-500">{product.cabinClass} Class</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-4xl font-bold text-gray-900">${product.basePrice}</p>
|
||||
{product.refundable && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-xs font-medium">
|
||||
Refundable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.departure.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.departure.airport}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-8 flex flex-col items-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{product.duration}</p>
|
||||
<div className="w-full h-0.5 bg-gray-200 relative flex items-center justify-center">
|
||||
<div className="absolute w-3 h-3 bg-gray-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.arrival.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.arrival.airport}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-6 border-t">
|
||||
<div className="text-gray-600">
|
||||
<span className="font-bold text-gray-900">{product.availability}</span> seats remaining
|
||||
<span className="mx-2">•</span>
|
||||
{product.fareRule}
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
@@ -11,18 +12,6 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const AmenityIcon = ({ name }: { name: string }) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
wifi: 'Wi-Fi',
|
||||
@@ -39,13 +28,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
const titleRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: hotel.name },
|
||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: 'price' },
|
||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -53,7 +42,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
roomType: hotel.roomType,
|
||||
price: hotel.pricePerNight,
|
||||
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="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section - Larger and cleaner */}
|
||||
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section - Full height/width usage */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b pb-6 mb-6">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
||||
<p className="text-xl text-gray-500">{product.roomType}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-in</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkIn}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-out</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkOut}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.amenities.map(a => (
|
||||
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.refundable && (
|
||||
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
|
||||
<span className="font-medium">Free cancellation available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<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() {
|
||||
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() {
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Find your perfect stay
|
||||
Find your perfect room
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Search hotels, compare prices, and book with confidence
|
||||
Search rooms, compare prices, and book with confidence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="destination">Where to?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -49,7 +62,7 @@ export default function HotelHero() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkIn">Check-in</Label>
|
||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
||||
<DateInput
|
||||
id="checkIn"
|
||||
value={checkIn}
|
||||
@@ -59,43 +72,27 @@ export default function HotelHero() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkOut">Check-out</Label>
|
||||
<DateInput
|
||||
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'}`}>
|
||||
<Label htmlFor="guests">Guests</Label>
|
||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
value={guests.adults}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Rooms"
|
||||
value={guests.rooms}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</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>
|
||||
Search Hotels
|
||||
Search Rooms
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
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;
|
||||
};
|
||||
75
web/src/lib/airline-utils.ts
Normal file
75
web/src/lib/airline-utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface AirlineProduct {
|
||||
id: string;
|
||||
flight_type: string;
|
||||
date_index: number;
|
||||
metadata: {
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabin_class: string;
|
||||
fare_rule: string;
|
||||
refundable: boolean;
|
||||
total?: number;
|
||||
base_price: number;
|
||||
};
|
||||
availability: number;
|
||||
}
|
||||
|
||||
export interface Flight {
|
||||
id: string;
|
||||
flightType: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: string;
|
||||
fareRule: string;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
dateIndex: number;
|
||||
availability: number;
|
||||
}
|
||||
|
||||
const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: AirlineProduct): Flight => {
|
||||
const { id, flight_type, date_index, metadata, availability } = p;
|
||||
|
||||
return {
|
||||
id,
|
||||
flightType: flight_type,
|
||||
departure: metadata.departure,
|
||||
arrival: metadata.arrival,
|
||||
duration: metadata.duration,
|
||||
stops: metadata.stops,
|
||||
cabinClass: metadata.cabin_class,
|
||||
fareRule: metadata.fare_rule,
|
||||
refundable: metadata.refundable,
|
||||
basePrice: metadata.base_price,
|
||||
dateIndex: date_index,
|
||||
availability,
|
||||
};
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
71
web/src/lib/hotel-utils.ts
Normal file
71
web/src/lib/hotel-utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
dateIndex: number;
|
||||
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' }),
|
||||
dateIndex: date_index,
|
||||
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);
|
||||
};
|
||||
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