mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
9 Commits
13-agentic
...
first-pric
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a57bc10b | |||
| 5b87fde8ed | |||
| 07262e5c8f | |||
| 633edcd76b | |||
| c69fb108f2 | |||
| c639d99be2 | |||
|
|
8b76d24ade | ||
|
|
894ce87a5d | ||
|
|
ab8b8787a8 |
@@ -11,6 +11,7 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
|||||||
from kafka.admin import NewTopic
|
from kafka.admin import NewTopic
|
||||||
from kafka.errors import TopicAlreadyExistsError
|
from kafka.errors import TopicAlreadyExistsError
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from supabase import create_client, Client
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -18,6 +19,19 @@ app = FastAPI()
|
|||||||
# kafka producer - lazy init
|
# kafka producer - lazy init
|
||||||
_producer: Optional[KafkaProducer] = None
|
_producer: Optional[KafkaProducer] = None
|
||||||
|
|
||||||
|
# supabase client - lazy init
|
||||||
|
_supabase: Optional[Client] = None
|
||||||
|
|
||||||
|
def get_supabase() -> Client:
|
||||||
|
global _supabase
|
||||||
|
if _supabase is None:
|
||||||
|
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
||||||
|
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||||
|
if not url or not key:
|
||||||
|
raise ValueError("Supabase credentials not configured")
|
||||||
|
_supabase = create_client(url, key)
|
||||||
|
return _supabase
|
||||||
|
|
||||||
def get_producer() -> KafkaProducer:
|
def get_producer() -> KafkaProducer:
|
||||||
global _producer
|
global _producer
|
||||||
if _producer is None:
|
if _producer is None:
|
||||||
@@ -41,6 +55,7 @@ def get_producer() -> KafkaProducer:
|
|||||||
|
|
||||||
class EventPayload(BaseModel):
|
class EventPayload(BaseModel):
|
||||||
sessionId: str
|
sessionId: str
|
||||||
|
experimentId: Optional[str] = None
|
||||||
eventName: str
|
eventName: str
|
||||||
page: str
|
page: str
|
||||||
productId: Optional[str] = None
|
productId: Optional[str] = None
|
||||||
@@ -49,6 +64,14 @@ class EventPayload(BaseModel):
|
|||||||
userAgent: Optional[str] = None
|
userAgent: Optional[str] = None
|
||||||
ts: Optional[str] = None
|
ts: Optional[str] = None
|
||||||
|
|
||||||
|
class PriceLogPayload(BaseModel):
|
||||||
|
productId: str
|
||||||
|
price: float
|
||||||
|
sessionId: str
|
||||||
|
experimentId: Optional[str] = None
|
||||||
|
storeMode: str
|
||||||
|
ts: Optional[str] = None
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -72,7 +95,8 @@ async def startup_event():
|
|||||||
)
|
)
|
||||||
|
|
||||||
topics = [
|
topics = [
|
||||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1)
|
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||||
|
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||||
]
|
]
|
||||||
|
|
||||||
admin.create_topics(new_topics=topics, validate_only=False)
|
admin.create_topics(new_topics=topics, validate_only=False)
|
||||||
@@ -124,26 +148,52 @@ async def ingest_logs(event: EventPayload):
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/kafka/price-log")
|
||||||
|
async def ingest_price_log(price_log: PriceLogPayload):
|
||||||
|
try:
|
||||||
|
if not price_log.ts:
|
||||||
|
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
producer = get_producer()
|
||||||
|
future = producer.send(
|
||||||
|
'price-logs',
|
||||||
|
key=price_log.productId,
|
||||||
|
value=price_log.model_dump()
|
||||||
|
)
|
||||||
|
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[PRICE_LOG_ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.get("/api/kafka/dump")
|
@app.get("/api/kafka/dump")
|
||||||
def dump_logs(
|
def dump_logs(
|
||||||
|
topic: str = 'user-interactions',
|
||||||
last_n: Optional[int] = None,
|
last_n: Optional[int] = None,
|
||||||
t_start: Optional[str] = None,
|
t_start: Optional[str] = None,
|
||||||
t_end: Optional[str] = None
|
t_end: Optional[str] = None
|
||||||
):
|
):
|
||||||
"""dump all messages from user-interactions topic
|
"""dump all messages from specified kafka topic
|
||||||
|
|
||||||
params:
|
params:
|
||||||
|
topic: kafka topic to dump (default: user-interactions)
|
||||||
last_n: return only last n messages (default: all)
|
last_n: return only last n messages (default: all)
|
||||||
t_start: filter by start timestamp iso format (future use)
|
t_start: filter by start timestamp iso format
|
||||||
t_end: filter by end timestamp iso format (future use)
|
t_end: filter by end timestamp iso format
|
||||||
"""
|
"""
|
||||||
|
if topic not in ['user-interactions', 'price-logs']:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid topic")
|
||||||
|
|
||||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
broker = f'{host}:{port}'
|
broker = f'{host}:{port}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
consumer = KafkaConsumer(
|
consumer = KafkaConsumer(
|
||||||
'user-interactions',
|
topic,
|
||||||
bootstrap_servers=[broker],
|
bootstrap_servers=[broker],
|
||||||
auto_offset_reset='earliest',
|
auto_offset_reset='earliest',
|
||||||
enable_auto_commit=False,
|
enable_auto_commit=False,
|
||||||
@@ -159,7 +209,6 @@ def dump_logs(
|
|||||||
|
|
||||||
# apply filters
|
# apply filters
|
||||||
if t_start or t_end:
|
if t_start or t_end:
|
||||||
# filter by timestamp range if provided
|
|
||||||
filtered = []
|
filtered = []
|
||||||
for e in events:
|
for e in events:
|
||||||
ts = e.get('ts')
|
ts = e.get('ts')
|
||||||
@@ -182,6 +231,130 @@ def dump_logs(
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/products/{product_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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0
|
|||||||
kafka-python==2.0.2
|
kafka-python==2.0.2
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
supabase==2.9.1
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_PORT=5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- kafka
|
- kafka
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
JTBD= "Name all the products on this site and try to find out more about each product by clicking into them (they might not open)"
|
JTBD= "Find me the cheapest room in Madrid for 2 people in the next two days, review each hotel room in detail and then add it to cart."
|
||||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal=JTBD, url="http://localhost:3000/products", timeout=300)
|
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||||
|
goal=JTBD,
|
||||||
|
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
||||||
|
timeout=300)
|
||||||
R=asyncio.run(agent.act())
|
R=asyncio.run(agent.act())
|
||||||
print(R)
|
print(R)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
119
experiments/procesing/demand.py
Normal file
119
experiments/procesing/demand.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from typing import Optional, Literal
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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 ChunkInteractionsIntoSteps(BaseEstimator, TransformerMixin):
|
||||||
|
"""
|
||||||
|
Split interaction data into time windows for temporal analysis.
|
||||||
|
Returns a list of dataframes, one per time window.
|
||||||
|
"""
|
||||||
|
def __init__(self,
|
||||||
|
window_size:str='1h',
|
||||||
|
ts_col:str='ts',
|
||||||
|
return_metadata:bool=True):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
window_size: pandas freq string ('1h', '30T', '1D', etc)
|
||||||
|
ts_col: timestamp column name
|
||||||
|
return_metadata: if True, return dict with metadata per chunk
|
||||||
|
"""
|
||||||
|
self.window_size = window_size
|
||||||
|
self.ts_col = ts_col
|
||||||
|
self.return_metadata = return_metadata
|
||||||
|
|
||||||
|
def fit(self, X):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, interactions: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
if return_metadata=False: list of dataframes, one per window
|
||||||
|
if return_metadata=True: list of dicts with keys:
|
||||||
|
- 'data': dataframe for this window
|
||||||
|
- 'window_start': start timestamp
|
||||||
|
- 'window_end': end timestamp
|
||||||
|
- 'window_idx': integer index
|
||||||
|
"""
|
||||||
|
if interactions.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = interactions.copy()
|
||||||
|
|
||||||
|
# ensure timestamp is datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[self.ts_col]):
|
||||||
|
df[self.ts_col] = pd.to_datetime(df[self.ts_col])
|
||||||
|
|
||||||
|
# sort by time
|
||||||
|
df = df.sort_values(self.ts_col)
|
||||||
|
|
||||||
|
# assign window
|
||||||
|
df['_window'] = df[self.ts_col].dt.floor(self.window_size)
|
||||||
|
|
||||||
|
# group by window
|
||||||
|
chunks = []
|
||||||
|
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
||||||
|
chunk_data = group.drop(columns=['_window'])
|
||||||
|
|
||||||
|
if self.return_metadata:
|
||||||
|
chunks.append({
|
||||||
|
'data': chunk_data,
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_start + pd.Timedelta(self.window_size),
|
||||||
|
'window_idx': idx
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
chunks.append(chunk_data)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
log.info(f"Demand estimator found {len(unique_products)} in data")
|
||||||
|
|
||||||
|
# filter out rows without productId
|
||||||
|
interactions_with_products = interactions.dropna(subset=['productId'])
|
||||||
|
|
||||||
|
if interactions_with_products.empty:
|
||||||
|
# no interactions with products, return all zeros
|
||||||
|
return pd.DataFrame({
|
||||||
|
'productId': unique_products,
|
||||||
|
'demand_score': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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_with_products['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
|
||||||
333
experiments/procesing/elasticity.py
Normal file
333
experiments/procesing/elasticity.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from supabase import create_client, Client
|
||||||
|
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 TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
||||||
|
"""
|
||||||
|
Compute price elasticity from time-series demand and price data.
|
||||||
|
|
||||||
|
Elasticity = (% change in quantity) / (% change in price)
|
||||||
|
|
||||||
|
Works with chunked time-window data from ChunkInteractionsIntoSteps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
method:str='point',
|
||||||
|
min_observations:int=2,
|
||||||
|
smooth_window:Optional[int]=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
method: 'point' (point elasticity) or 'arc' (arc elasticity)
|
||||||
|
min_observations: min data points needed per product
|
||||||
|
smooth_window: if set, apply rolling avg smoothing to time series
|
||||||
|
"""
|
||||||
|
self.method = method
|
||||||
|
self.min_observations = min_observations
|
||||||
|
self.smooth_window = smooth_window
|
||||||
|
|
||||||
|
def fit(self, X):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self,
|
||||||
|
demand_chunks: List[Dict],
|
||||||
|
price_chunks: List[Dict],
|
||||||
|
store_mode: str = 'hotel') -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
demand_chunks: list from ChunkInteractionsIntoSteps + DemandEstimator
|
||||||
|
each item: {'window_start', 'window_end', 'demand_vector'}
|
||||||
|
price_chunks: list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||||
|
store_mode: 'hotel' or 'airline' to fetch all products
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
df with [productId, elasticity, std_error, n_observations]
|
||||||
|
"""
|
||||||
|
# fetch all products from database
|
||||||
|
all_products = supabase.table(f'{store_mode}_products').select("id").execute()
|
||||||
|
all_product_ids = [p['id'] for p in all_products.data]
|
||||||
|
|
||||||
|
aligned = self._align_chunks(demand_chunks, price_chunks)
|
||||||
|
if not aligned:
|
||||||
|
# return all products with zero elasticity
|
||||||
|
return pd.DataFrame({
|
||||||
|
'productId': all_product_ids,
|
||||||
|
'elasticity': 0.0,
|
||||||
|
'std_error': 0.0,
|
||||||
|
'n_obs': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# build time series per product
|
||||||
|
product_series = self._build_product_timeseries(aligned)
|
||||||
|
|
||||||
|
# compute elasticity per product
|
||||||
|
elasticities = []
|
||||||
|
for pid, series in product_series.items():
|
||||||
|
if len(series) < self.min_observations:
|
||||||
|
# assign 0 elasticity for products with insufficient data
|
||||||
|
elasticities.append({
|
||||||
|
'productId': pid,
|
||||||
|
'elasticity': 0.0,
|
||||||
|
'std_error': 0.0,
|
||||||
|
'n_obs': len(series)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# apply smoothing if requested
|
||||||
|
if self.smooth_window and len(series) >= self.smooth_window:
|
||||||
|
series = self._smooth_series(series, self.smooth_window)
|
||||||
|
|
||||||
|
elast = self._compute_elasticity(series)
|
||||||
|
elasticities.append({
|
||||||
|
'productId': pid,
|
||||||
|
'elasticity': elast['value'],
|
||||||
|
'std_error': elast.get('std_error', 0.0),
|
||||||
|
'n_obs': len(series)
|
||||||
|
})
|
||||||
|
|
||||||
|
result_df = pd.DataFrame(elasticities)
|
||||||
|
|
||||||
|
# fill in missing products with zero elasticity
|
||||||
|
observed_pids = set(result_df['productId'].unique())
|
||||||
|
missing_pids = [pid for pid in all_product_ids if pid not in observed_pids]
|
||||||
|
|
||||||
|
if missing_pids:
|
||||||
|
missing_df = pd.DataFrame({
|
||||||
|
'productId': missing_pids,
|
||||||
|
'elasticity': 0.0,
|
||||||
|
'std_error': 0.0,
|
||||||
|
'n_obs': 0
|
||||||
|
})
|
||||||
|
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
||||||
|
|
||||||
|
return result_df
|
||||||
|
|
||||||
|
def _align_chunks(self, demand_chunks, price_chunks):
|
||||||
|
"""Align demand and price data by matching time windows."""
|
||||||
|
aligned = []
|
||||||
|
|
||||||
|
# create lookup for price chunks by window_start
|
||||||
|
price_lookup = {chunk['window_start']: chunk for chunk in price_chunks}
|
||||||
|
|
||||||
|
for demand_chunk in demand_chunks:
|
||||||
|
window_start = demand_chunk['window_start']
|
||||||
|
if window_start in price_lookup:
|
||||||
|
aligned.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': demand_chunk['window_end'],
|
||||||
|
'demand': demand_chunk['demand_vector'],
|
||||||
|
'prices': price_lookup[window_start]['price_vector']
|
||||||
|
})
|
||||||
|
|
||||||
|
return aligned
|
||||||
|
|
||||||
|
def _build_product_timeseries(self, aligned_chunks):
|
||||||
|
"""Build time series [price, quantity] per product."""
|
||||||
|
series_by_product = {}
|
||||||
|
|
||||||
|
for chunk in aligned_chunks:
|
||||||
|
demand_df = chunk['demand']
|
||||||
|
price_df = chunk['prices']
|
||||||
|
|
||||||
|
# merge on productId
|
||||||
|
merged = demand_df.merge(price_df, on='productId', how='inner')
|
||||||
|
|
||||||
|
for _, row in merged.iterrows():
|
||||||
|
pid = row['productId']
|
||||||
|
if pid not in series_by_product:
|
||||||
|
series_by_product[pid] = []
|
||||||
|
|
||||||
|
series_by_product[pid].append({
|
||||||
|
'timestamp': chunk['window_start'],
|
||||||
|
'price': row['price'],
|
||||||
|
'quantity': row['demand_score']
|
||||||
|
})
|
||||||
|
|
||||||
|
return series_by_product
|
||||||
|
|
||||||
|
def _smooth_series(self, series, window):
|
||||||
|
"""Apply rolling average smoothing."""
|
||||||
|
df = pd.DataFrame(series)
|
||||||
|
df['price_smooth'] = df['price'].rolling(window=window, center=True).mean()
|
||||||
|
df['quantity_smooth'] = df['quantity'].rolling(window=window, center=True).mean()
|
||||||
|
df = df.dropna()
|
||||||
|
|
||||||
|
return [{'timestamp': row['timestamp'],
|
||||||
|
'price': row['price_smooth'],
|
||||||
|
'quantity': row['quantity_smooth']}
|
||||||
|
for _, row in df.iterrows()]
|
||||||
|
|
||||||
|
def _compute_elasticity(self, series):
|
||||||
|
"""Compute elasticity from time series."""
|
||||||
|
if len(series) < 2:
|
||||||
|
return {'value': 0.0, 'std_error': 0.0}
|
||||||
|
|
||||||
|
prices = np.array([s['price'] for s in series])
|
||||||
|
quantities = np.array([s['quantity'] for s in series])
|
||||||
|
|
||||||
|
# filter out zero/negative values
|
||||||
|
valid = (prices > 0) & (quantities > 0)
|
||||||
|
if valid.sum() < 2:
|
||||||
|
return {'value': 0.0, 'std_error': 0.0}
|
||||||
|
|
||||||
|
prices = prices[valid]
|
||||||
|
quantities = quantities[valid]
|
||||||
|
|
||||||
|
if self.method == 'point':
|
||||||
|
return self._point_elasticity(prices, quantities)
|
||||||
|
elif self.method == 'arc':
|
||||||
|
return self._arc_elasticity(prices, quantities)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {self.method}")
|
||||||
|
|
||||||
|
def _point_elasticity(self, prices, quantities):
|
||||||
|
"""
|
||||||
|
Point elasticity using log-log regression.
|
||||||
|
log(Q) = a + b*log(P), elasticity = b
|
||||||
|
"""
|
||||||
|
if len(prices) < 2:
|
||||||
|
return {'value': 0.0, 'std_error': 0.0}
|
||||||
|
|
||||||
|
log_p = np.log(prices)
|
||||||
|
log_q = np.log(quantities)
|
||||||
|
|
||||||
|
# simple linear regression
|
||||||
|
if log_p.std() == 0:
|
||||||
|
return {'value': 0.0, 'std_error': 0.0}
|
||||||
|
|
||||||
|
cov = np.cov(log_p, log_q)[0, 1]
|
||||||
|
var = np.var(log_p)
|
||||||
|
b = cov / var
|
||||||
|
|
||||||
|
# std error estimate (avoid div by zero)
|
||||||
|
if len(prices) <= 2:
|
||||||
|
se_b = 0.0
|
||||||
|
else:
|
||||||
|
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
||||||
|
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
||||||
|
se_b = np.sqrt(mse / (len(prices) * var))
|
||||||
|
|
||||||
|
return {'value': b, 'std_error': se_b}
|
||||||
|
|
||||||
|
def _arc_elasticity(self, prices, quantities):
|
||||||
|
"""
|
||||||
|
Arc elasticity: average of period-over-period elasticities.
|
||||||
|
E_t = (ΔQ/Q_avg) / (ΔP/P_avg)
|
||||||
|
"""
|
||||||
|
elasticities = []
|
||||||
|
|
||||||
|
for i in range(1, len(prices)):
|
||||||
|
p1, p2 = prices[i-1], prices[i]
|
||||||
|
q1, q2 = quantities[i-1], quantities[i]
|
||||||
|
|
||||||
|
p_avg = (p1 + p2) / 2
|
||||||
|
q_avg = (q1 + q2) / 2
|
||||||
|
|
||||||
|
if p_avg == 0 or q_avg == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
delta_p = p2 - p1
|
||||||
|
delta_q = q2 - q1
|
||||||
|
|
||||||
|
if delta_p == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
e = (delta_q / q_avg) / (delta_p / p_avg)
|
||||||
|
elasticities.append(e)
|
||||||
|
|
||||||
|
if not elasticities:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'value': np.mean(elasticities),
|
||||||
|
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_price_logs(price_logs: pd.DataFrame,
|
||||||
|
window_size: str = '1H',
|
||||||
|
ts_col: str = 'ts',
|
||||||
|
store_mode : str = 'hotel') -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Recover price vectors treating prices as persistent state changes.
|
||||||
|
|
||||||
|
Prices are set-operations that persist until next change. For each window:
|
||||||
|
- If price logs exist: average all changes within window
|
||||||
|
- If no logs: carry forward last price before window end
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_logs: df with [productId, price, ts, ...]
|
||||||
|
window_size: time window size matching ChunkInteractionsIntoSteps
|
||||||
|
ts_col: timestamp column name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||||
|
where price_vector is df with [productId, price]
|
||||||
|
"""
|
||||||
|
if price_logs.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = price_logs.copy()
|
||||||
|
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values([ts_col, 'productId'])
|
||||||
|
all_products=supabase.table(f'{store_mode}_products').select("id, room_type, date_index, metadata, availability").execute()
|
||||||
|
all_products = pd.DataFrame(all_products.data)
|
||||||
|
unique_products = all_products['id'].unique()
|
||||||
|
|
||||||
|
# generate windows across data range
|
||||||
|
min_time, max_time = df[ts_col].min(), df[ts_col].max()
|
||||||
|
windows = pd.date_range(
|
||||||
|
start=min_time.floor(window_size),
|
||||||
|
end=max_time,
|
||||||
|
freq=window_size
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
for window_start in windows:
|
||||||
|
window_end = window_start + pd.Timedelta(window_size)
|
||||||
|
price_vector = []
|
||||||
|
|
||||||
|
# all products with price history by window_end
|
||||||
|
#historical_products = df[df[ts_col] < window_end]['productId'].unique()
|
||||||
|
historical_products = unique_products.tolist()
|
||||||
|
|
||||||
|
for pid in historical_products:
|
||||||
|
product_data = df[df['productId'] == pid]
|
||||||
|
|
||||||
|
# logs within window
|
||||||
|
in_window = product_data[
|
||||||
|
(product_data[ts_col] >= window_start) &
|
||||||
|
(product_data[ts_col] < window_end)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not in_window.empty:
|
||||||
|
# average changes within window
|
||||||
|
price = in_window['price'].mean()
|
||||||
|
else:
|
||||||
|
# carry forward: last price before window end
|
||||||
|
before_window = product_data[product_data[ts_col] < window_end]
|
||||||
|
if before_window.empty:
|
||||||
|
continue
|
||||||
|
price = before_window['price'].iloc[-1]
|
||||||
|
|
||||||
|
price_vector.append({'productId': pid, 'price': price})
|
||||||
|
|
||||||
|
if price_vector:
|
||||||
|
chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_end,
|
||||||
|
'price_vector': pd.DataFrame(price_vector)
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
@@ -5,14 +5,26 @@ import os
|
|||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from typing import Tuple, List, Dict
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000")
|
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000")
|
||||||
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||||
N_PRICE_BUCKETS = 5
|
N_PRICE_BUCKETS = 5
|
||||||
|
|
||||||
def get_data_from_kafka() -> pd.DataFrame:
|
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||||
"""fetch all events from backend dump endpoint"""
|
|
||||||
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump")
|
|
||||||
|
class KafkaDataFetcher(BaseEstimator, TransformerMixin):
|
||||||
|
def __init__(self, topic: str = "user-interactions"):
|
||||||
|
self.topic = topic # also can be price-logs
|
||||||
|
def fit(self, X=None, y=None):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump?topic={self.topic}")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
@@ -20,19 +32,60 @@ def get_data_from_kafka() -> pd.DataFrame:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
df = pd.DataFrame(data['data'])
|
df = pd.DataFrame(data['data'])
|
||||||
# explode metadata col json
|
if self.topic == 'user-interactions':
|
||||||
if 'metadata' in df.columns:
|
if 'metadata' in df.columns: # explode metadata col json
|
||||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
||||||
df = df.dropna(subset=['eventName'])
|
df = df.dropna(subset=['eventName'])
|
||||||
|
# remape dateIndex
|
||||||
|
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame:
|
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
||||||
# TODO: Get experiments db from supabase and join on session_id
|
def fit(self, X=None, y=None):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, df):
|
||||||
|
if df.empty or 'experimentId' not in df.columns:
|
||||||
|
return df
|
||||||
|
|
||||||
|
unique_exp_ids = df['experimentId'].dropna().unique()
|
||||||
|
if len(unique_exp_ids) == 0:
|
||||||
|
return df
|
||||||
|
|
||||||
|
resp = supabase.table('experiments').select(
|
||||||
|
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)'
|
||||||
|
).in_('id', unique_exp_ids.tolist()).execute()
|
||||||
|
|
||||||
|
if not resp.data:
|
||||||
|
return df
|
||||||
|
|
||||||
|
exp_df = pd.DataFrame(resp.data)
|
||||||
|
|
||||||
|
# flatten task nested object if present
|
||||||
|
if 'task' in exp_df.columns and exp_df['task'].notnull().any():
|
||||||
|
task_normalized = pd.json_normalize(exp_df['task'].dropna())
|
||||||
|
task_normalized.index = exp_df[exp_df['task'].notnull()].index
|
||||||
|
exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task')
|
||||||
|
|
||||||
|
# rename experiment columns for clarity
|
||||||
|
exp_df = exp_df.rename(columns={
|
||||||
|
'id': 'experimentId',
|
||||||
|
'subject_name': 'exp_subject',
|
||||||
|
'xp_human_only': 'exp_human_only',
|
||||||
|
'xp_market_mode': 'exp_market_mode',
|
||||||
|
'xp_task_id': 'exp_task_id'
|
||||||
|
})
|
||||||
|
|
||||||
|
df = df.merge(exp_df, on='experimentId', how='left')
|
||||||
return df
|
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}
|
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
||||||
# we want metadata schema to create product specific event names
|
# we want metadata schema to create product specific event names
|
||||||
|
|
||||||
@@ -62,23 +115,93 @@ def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
def extract() -> pd.DataFrame:
|
def chunk_shared_data(interactions_df: pd.DataFrame,
|
||||||
df = get_data_from_kafka()
|
price_logs_df: pd.DataFrame,
|
||||||
df = join_with_experiments(df)
|
window_size: str = '30s',
|
||||||
df = augment_event_titles(df)
|
ts_col: str = 'ts') -> Tuple[List[Dict], List[Dict]]:
|
||||||
return df
|
"""
|
||||||
|
Chunk interaction and price data into aligned time windows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interactions_df: interaction data with timestamp column
|
||||||
|
price_logs_df: price log data with timestamp column
|
||||||
|
window_size: pandas freq string ('30s', '1min', '1h', etc)
|
||||||
|
ts_col: name of timestamp column
|
||||||
|
|
||||||
class DataExtractor(BaseEstimator, TransformerMixin):
|
Returns:
|
||||||
def fit(self, X=None, y=None):
|
tuple of (interaction_chunks, price_chunks) where each is list of dicts:
|
||||||
return self
|
{
|
||||||
|
'window_start': timestamp,
|
||||||
|
'window_end': timestamp,
|
||||||
|
'data': dataframe for this window
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if interactions_df.empty and price_logs_df.empty:
|
||||||
|
return [], []
|
||||||
|
|
||||||
def transform(self, X=None):
|
# convert timestamps to datetime
|
||||||
return extract()
|
interactions_df = interactions_df.copy()
|
||||||
|
price_logs_df = price_logs_df.copy()
|
||||||
|
|
||||||
|
if not interactions_df.empty:
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(interactions_df[ts_col]):
|
||||||
|
interactions_df[ts_col] = pd.to_datetime(interactions_df[ts_col])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if not price_logs_df.empty:
|
||||||
df = extract()
|
if not pd.api.types.is_datetime64_any_dtype(price_logs_df[ts_col]):
|
||||||
print(df.head())
|
price_logs_df[ts_col] = pd.to_datetime(price_logs_df[ts_col])
|
||||||
print(df.tail())
|
|
||||||
print(df.info())
|
# find global time bounds
|
||||||
|
times = []
|
||||||
|
if not interactions_df.empty:
|
||||||
|
times.extend([interactions_df[ts_col].min(), interactions_df[ts_col].max()])
|
||||||
|
if not price_logs_df.empty:
|
||||||
|
times.extend([price_logs_df[ts_col].min(), price_logs_df[ts_col].max()])
|
||||||
|
|
||||||
|
if not times:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
earliest = min(times)
|
||||||
|
latest = max(times)
|
||||||
|
|
||||||
|
# create shared time windows
|
||||||
|
windows = pd.date_range(start=earliest, end=latest, freq=window_size)
|
||||||
|
|
||||||
|
if len(windows) < 2:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
# chunk both datasets
|
||||||
|
interaction_chunks = []
|
||||||
|
price_chunks = []
|
||||||
|
|
||||||
|
for i in range(len(windows) - 1):
|
||||||
|
window_start = windows[i]
|
||||||
|
window_end = windows[i + 1]
|
||||||
|
|
||||||
|
# filter interactions in this window
|
||||||
|
if not interactions_df.empty:
|
||||||
|
mask = (interactions_df[ts_col] >= window_start) & (interactions_df[ts_col] < window_end)
|
||||||
|
interaction_chunk = interactions_df[mask]
|
||||||
|
else:
|
||||||
|
interaction_chunk = pd.DataFrame()
|
||||||
|
|
||||||
|
interaction_chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_end,
|
||||||
|
'data': interaction_chunk
|
||||||
|
})
|
||||||
|
|
||||||
|
# filter price logs in this window
|
||||||
|
if not price_logs_df.empty:
|
||||||
|
mask = (price_logs_df[ts_col] >= window_start) & (price_logs_df[ts_col] < window_end)
|
||||||
|
price_chunk = price_logs_df[mask]
|
||||||
|
else:
|
||||||
|
price_chunk = pd.DataFrame()
|
||||||
|
|
||||||
|
price_chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_end,
|
||||||
|
'data': price_chunk
|
||||||
|
})
|
||||||
|
|
||||||
|
return interaction_chunks, price_chunks
|
||||||
|
|||||||
@@ -1,19 +1,90 @@
|
|||||||
from sklearn.pipeline import Pipeline
|
from sklearn.pipeline import Pipeline
|
||||||
from sklearn.preprocessing import StandardScaler
|
from sklearn.preprocessing import StandardScaler
|
||||||
from extract import DataExtractor
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter, chunk_shared_data
|
||||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||||
|
from demand import DemandEstimator, ChunkInteractionsIntoSteps
|
||||||
|
from elasticity import TemporalElasticityEstimator, aggregate_price_logs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# elasticity pipeline components (not sklearn compatible, manual orchestration)
|
||||||
|
def elasticity_pipeline(interactions_df, price_logs_df, window_size='30s', store_mode='hotel'):
|
||||||
|
"""
|
||||||
|
Compute price elasticity from interaction and price data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interactions_df: raw interaction data from demand_data_pipeline
|
||||||
|
price_logs_df: price log data from price_data_pipeline
|
||||||
|
window_size: time window for chunking
|
||||||
|
store_mode: 'hotel' or 'airline'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
df with [productId, elasticity, std_error, n_obs]
|
||||||
|
"""
|
||||||
|
# step 1: chunk interactions into time windows
|
||||||
|
chunker = ChunkInteractionsIntoSteps(window_size=window_size, return_metadata=True)
|
||||||
|
interaction_chunks = chunker.transform(interactions_df)
|
||||||
|
log.info(f"Chunked interactions into {len(interaction_chunks)} windows of size {window_size}")
|
||||||
|
|
||||||
|
if not interaction_chunks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# step 2: compute demand per window
|
||||||
|
demand_estimator = DemandEstimator(store_mode=store_mode)
|
||||||
|
demand_chunks = []
|
||||||
|
for chunk in interaction_chunks:
|
||||||
|
demand_vector = demand_estimator.transform(chunk['data'])
|
||||||
|
demand_chunks.append({
|
||||||
|
'window_start': chunk['window_start'],
|
||||||
|
'window_end': chunk['window_end'],
|
||||||
|
'demand_vector': demand_vector # each has a full list of all products, even if demand is 0
|
||||||
|
})
|
||||||
|
# [q_chunk1, q_chunk2, ...]
|
||||||
|
|
||||||
|
# step 3: aggregate price logs into windows
|
||||||
|
price_chunks = aggregate_price_logs(price_logs_df, window_size=window_size)
|
||||||
|
|
||||||
|
# step 4: compute elasticity
|
||||||
|
elasticity_estimator = TemporalElasticityEstimator(method='point', min_observations=2)
|
||||||
|
elasticity_df = elasticity_estimator.transform(demand_chunks, price_chunks, store_mode=store_mode)
|
||||||
|
|
||||||
|
return elasticity_df
|
||||||
|
|
||||||
|
|
||||||
|
# exposable pipelines
|
||||||
|
interaction_pipeline = Pipeline([
|
||||||
|
('kafka_fetch', KafkaDataFetcher(topic='user-interactions')),
|
||||||
|
('experiment_join', ExperimentJoiner()),
|
||||||
|
('event_augment', EventTitleAugmenter()),
|
||||||
|
])
|
||||||
|
|
||||||
|
price_data_pipeline = Pipeline([
|
||||||
|
('kafka_fetch', KafkaDataFetcher(topic='price-logs')),
|
||||||
|
])
|
||||||
|
|
||||||
|
# interaction_data + price_data -> elasticity (demand)
|
||||||
|
# elasticity -> pricing
|
||||||
|
|
||||||
|
pricing_pipeline = Pipeline([
|
||||||
|
('demand_estimation', DemandEstimator()),
|
||||||
|
])
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
steps = [
|
# fetch both datasets
|
||||||
('data_extraction', DataExtractor()),
|
interaction_data = interaction_pipeline.fit_transform(None)
|
||||||
('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)),
|
pricing_data = price_data_pipeline.fit_transform(None)
|
||||||
]
|
if interaction_data.empty or pricing_data.empty:
|
||||||
pipeline = Pipeline(steps)
|
print("Insufficient data for elasticity computation"); exit(0)
|
||||||
result = pipeline.fit_transform(None)
|
# compute elasticity via unified pipeline
|
||||||
print(f"Number of sessions: {len(result)}\n")
|
window_size = "30s"
|
||||||
|
elasticity_results = elasticity_pipeline(interaction_data, pricing_data, window_size=window_size)
|
||||||
|
elasticity_value_array = elasticity_results['elasticity'].values if elasticity_results is not None else np.array([])
|
||||||
|
print(elasticity_value_array)
|
||||||
|
|
||||||
for session_id, sess_data in result.items():
|
if elasticity_results is not None and not elasticity_results.empty:
|
||||||
fname = f"session_{session_id}"
|
print(elasticity_results.to_string(index=False))
|
||||||
render_graph(fname, sess_data['matrix'], ls_index=sess_data['labels'], threshold=0.05, fmt="svg", view=False)
|
else:
|
||||||
print(f"Rendered {fname}.svg")
|
print("\nInsufficient data for elasticity computation")
|
||||||
|
|||||||
153
experiments/procesing/pricing.py
Normal file
153
experiments/procesing/pricing.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
r"""
|
||||||
|
Our state space comes as:
|
||||||
|
$Q_t in R^n$ - our demand at a time t
|
||||||
|
$P_t in R^n$ - prices at time t
|
||||||
|
$S_t$ some form of interaction session features
|
||||||
|
|
||||||
|
This is a single sate which we map under
|
||||||
|
|
||||||
|
$f: (Q, S, H) \to P_{t+1}$
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
$H_t = \{Q_{t-k}, P_{t-k}, S_{t-k}\}$
|
||||||
|
|
||||||
|
|
||||||
|
We can have f be literally anything, analytical or learned or rule based or an RL policy.
|
||||||
|
|
||||||
|
Our goal is to mazimize the expected revenue:
|
||||||
|
|
||||||
|
$E[R_T] = E[\sum_{t=1}^T P_t^T \dot Q_t]$
|
||||||
|
|
||||||
|
subject to Q_t = g(P_t, S_t) : demand response to price (estimated via elasticity) and P_t ≥ C : prices above cost floor and additionally minimizing the following:
|
||||||
|
|
||||||
|
$L_{agent} = R_{oracle} - R_{observed}
|
||||||
|
|
||||||
|
where: R_oracle = revenue if we knew agent intentions (from recon session) and R_observed = revenue under current pricing policy f
|
||||||
|
|
||||||
|
I would start be defning a pricing function interface and standardizing how to train that based on historical data and define how to make it behave for online training (if we do that)
|
||||||
|
|
||||||
|
We also need to develop a solid benchmark with mapping revenue and full KPIs from session interactions to measure differences between different price learning methods
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def expected_revenue(prices: np.ndarray, demand: np.ndarray) -> float:
|
||||||
|
"""Returns: expected revenue R_t = P_t^T * Q_t"""
|
||||||
|
return float(np.dot(prices, demand))
|
||||||
|
|
||||||
|
class StateSpace:
|
||||||
|
def __init__(self,
|
||||||
|
demand : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||||
|
prices : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||||
|
session_features : pd.DataFrame):
|
||||||
|
self.demand = demand # Q_t
|
||||||
|
self.prices = prices # P_t
|
||||||
|
self.session_features = session_features # S_t
|
||||||
|
self.history = [] # H_t
|
||||||
|
|
||||||
|
class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fit(self, historical_data):
|
||||||
|
"""
|
||||||
|
Train the pricing function based on historical data.
|
||||||
|
historical_data: list of StateSpace instances with known outcomes
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Train method must be implemented by subclass.")
|
||||||
|
|
||||||
|
def transform(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Predict the next prices given the current state space.
|
||||||
|
state_space: StateSpace instance
|
||||||
|
Returns: predicted prices P_{t+1}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Predict method must be implemented by subclass.")
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleLinearPricingFunction(PricingFunction):
|
||||||
|
def __init__(self, price_sensitivity: float = -0.1):
|
||||||
|
super().__init__()
|
||||||
|
self.price_sensitivity = price_sensitivity # simple coefficient
|
||||||
|
|
||||||
|
def fit(self, historical_data):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace) -> np.ndarray:
|
||||||
|
# Simple linear adjustment: P_{t+1} = P_t + sensitivity * Q_t
|
||||||
|
new_prices = state_space.prices + self.price_sensitivity * state_space.demand # this is not great
|
||||||
|
return np.maximum(new_prices, 0)
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
if __name__ == "__main__":
|
||||||
|
store_mode = 'hotel'
|
||||||
|
interaction_data = interaction_pipeline.fit_transform(None)
|
||||||
|
price_data = price_data_pipeline.fit_transform(None)
|
||||||
|
|
||||||
|
elasticity_df = elasticity_pipeline(interaction_data, price_data, window_size="30s", store_mode=store_mode)
|
||||||
|
|
||||||
|
# fetch all products with base prices from database
|
||||||
|
products_resp = supabase.table(f'{store_mode}_products').select("id, metadata").execute()
|
||||||
|
products_df = pd.DataFrame(products_resp.data)
|
||||||
|
|
||||||
|
# extract base_price from metadata
|
||||||
|
products_df['base_price'] = products_df['metadata'].apply(lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0)
|
||||||
|
products_df = products_df.rename(columns={'id': 'productId'})[['productId', 'base_price']]
|
||||||
|
|
||||||
|
# override with logged prices where available
|
||||||
|
if not price_data.empty:
|
||||||
|
if 'ts' in price_data.columns and not pd.api.types.is_datetime64_any_dtype(price_data['ts']):
|
||||||
|
price_data['ts'] = pd.to_datetime(price_data['ts'])
|
||||||
|
|
||||||
|
# get latest logged price per product
|
||||||
|
price_logs_agg = price_data.sort_values('ts').groupby('productId', as_index=False).last()
|
||||||
|
|
||||||
|
# merge: start with all products (base prices), override with logged prices
|
||||||
|
products_df = products_df.merge(
|
||||||
|
price_logs_agg[['productId', 'price']],
|
||||||
|
on='productId',
|
||||||
|
how='left'
|
||||||
|
)
|
||||||
|
products_df['final_price'] = products_df['price'].fillna(products_df['base_price'])
|
||||||
|
else:
|
||||||
|
products_df['final_price'] = products_df['base_price']
|
||||||
|
|
||||||
|
# merge with elasticity
|
||||||
|
if elasticity_df is not None and not elasticity_df.empty:
|
||||||
|
price_data_merged = products_df[['productId', 'final_price']].merge(
|
||||||
|
elasticity_df[['productId', 'elasticity']],
|
||||||
|
on='productId',
|
||||||
|
how='left'
|
||||||
|
).fillna({'elasticity': 0.0})
|
||||||
|
|
||||||
|
prices = price_data_merged['final_price'].values
|
||||||
|
elasticities = price_data_merged['elasticity'].values
|
||||||
|
else:
|
||||||
|
prices = np.array([])
|
||||||
|
elasticities = np.array([])
|
||||||
|
|
||||||
|
print(elasticities)
|
||||||
|
print(prices)
|
||||||
|
|
||||||
|
state_space = StateSpace(
|
||||||
|
demand=elasticities,
|
||||||
|
prices=prices,
|
||||||
|
session_features=interaction_data
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_function = SimpleLinearPricingFunction(price_sensitivity=-0.05)
|
||||||
|
pricing_function.fit([]) # No training data for simple model
|
||||||
|
predicted_prices = pricing_function.transform(state_space)
|
||||||
|
|
||||||
|
print("Predicted Prices:", predicted_prices)
|
||||||
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)
|
# Add section header and code listing (no language-specific highlighting)
|
||||||
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
||||||
echo "\\begin{lstlisting}[caption={${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 "" >> "$OUTPUT_FILE"
|
||||||
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
||||||
echo "" >> "$OUTPUT_FILE"
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pytest
|
|||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
uv
|
uv
|
||||||
scikit-learn
|
scikit-learn
|
||||||
|
supabase
|
||||||
|
|||||||
140
web/package-lock.json
generated
140
web/package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.7.0",
|
||||||
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -657,6 +659,97 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.43.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.81.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
|
||||||
|
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.81.1",
|
||||||
|
"@supabase/functions-js": "2.81.1",
|
||||||
|
"@supabase/postgrest-js": "2.81.1",
|
||||||
|
"@supabase/realtime-js": "2.81.1",
|
||||||
|
"@supabase/storage-js": "2.81.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -941,12 +1034,17 @@
|
|||||||
"version": "20.19.23",
|
"version": "20.19.23",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
||||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||||
|
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
@@ -967,6 +1065,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001751",
|
"version": "1.0.30001751",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||||
@@ -993,6 +1100,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@@ -1605,9 +1721,29 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.7.0",
|
||||||
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSession } from '@/hooks/useSession';
|
import { TaskManager } from '@/components/admin/TaskManager';
|
||||||
|
import { ExperimentForm } from '@/components/admin/ExperimentForm';
|
||||||
|
|
||||||
type Experiment = {
|
type Experiment = {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'active' | 'stopped';
|
subject_name: string;
|
||||||
sessionIds: string[];
|
xp_human_only: boolean;
|
||||||
createdAt: number;
|
xp_market_mode: string;
|
||||||
|
created_at: string;
|
||||||
|
task?: {
|
||||||
|
id: string;
|
||||||
|
task_name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExperimentsAdmin() {
|
export default function ExperimentsAdmin() {
|
||||||
const { sessionId, isLoading: sessionLoading } = useSession();
|
|
||||||
const [exps, setExps] = useState<Experiment[]>([]);
|
const [exps, setExps] = useState<Experiment[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const fetchExps = async () => {
|
const fetchExps = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,87 +37,23 @@ export default function ExperimentsAdmin() {
|
|||||||
fetchExps();
|
fetchExps();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleExperimentCreated = async () => {
|
||||||
if (!sessionId) {
|
setShowForm(false);
|
||||||
setError('no session available');
|
setSelectedTaskId(undefined);
|
||||||
return;
|
await fetchExps();
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/experiments/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sessionId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || 'start failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchExps(); // refresh list
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (expId: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/experiments/stop', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ experimentId: expId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || 'stop failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchExps(); // refresh list
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
|
||||||
<p className="text-zinc-600 dark:text-zinc-400">loading session...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8">
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||||
Experiments
|
Experiment Management
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
current session: {sessionId || 'none'}
|
configure tasks and run experiments
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleStart}
|
|
||||||
disabled={loading || !sessionId}
|
|
||||||
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
|
||||||
>
|
|
||||||
{loading ? 'starting...' : 'start experiment'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||||
@@ -119,24 +61,57 @@ export default function ExperimentsAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* left column: task manager */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<TaskManager
|
||||||
|
onTaskSelect={setSelectedTaskId}
|
||||||
|
selectedTaskId={selectedTaskId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right column: experiment form + list */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Experiments
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
{showForm ? 'hide form' : 'new experiment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<ExperimentForm
|
||||||
|
selectedTaskId={selectedTaskId}
|
||||||
|
onSuccess={handleExperimentCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
experiment id
|
subject
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
status
|
mode
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
session count
|
human
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
task
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
created
|
created
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
action
|
link
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -144,56 +119,67 @@ export default function ExperimentsAdmin() {
|
|||||||
{exps.length === 0 ? (
|
{exps.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
colSpan={6}
|
||||||
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
no experiments yet
|
no experiments yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
exps.map((exp) => (
|
exps.map((exp) => {
|
||||||
|
const baseUrl = exp.xp_market_mode === 'airline'
|
||||||
|
? 'https://phantom-airline.vercel.app'
|
||||||
|
: 'https://phantom-hotel.vercel.app';
|
||||||
|
const link = `${baseUrl}/start-task?uuid=${exp.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={exp.id}
|
key={exp.id}
|
||||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
|
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
{exp.id.slice(0, 8)}...
|
{exp.subject_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span className="inline-block rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
{exp.xp_market_mode || 'none'}
|
||||||
exp.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
|
|
||||||
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{exp.status}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
<td className="px-4 py-3">
|
||||||
{exp.sessionIds.length}
|
{exp.xp_human_only ? (
|
||||||
</td>
|
<span className="text-xs text-green-600 dark:text-green-400">
|
||||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
yes
|
||||||
{new Date(exp.createdAt).toLocaleString()}
|
</span>
|
||||||
</td>
|
) : (
|
||||||
<td className="px-6 py-4">
|
<span className="text-xs text-zinc-500">no</span>
|
||||||
{exp.status === 'active' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleStop(exp.id)}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
stop
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
{exp.task ? exp.task.task_name : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
{new Date(exp.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
}}
|
||||||
|
className="text-xs font-medium text-zinc-900 hover:text-zinc-600 dark:text-zinc-100 dark:hover:text-zinc-400"
|
||||||
|
>
|
||||||
|
copy link
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||||
|
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
||||||
|
|
||||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
function FlightsList() {
|
||||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
const searchParams = useSearchParams();
|
||||||
|
const [flights, setFlights] = useState<Flight[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
interface Flight {
|
useEffect(() => {
|
||||||
id: string;
|
const fetchFlights = async () => {
|
||||||
departure: { time: string; airport: string };
|
try {
|
||||||
arrival: { time: string; airport: string };
|
const url = new URL('/api/products', window.location.origin);
|
||||||
duration: string;
|
url.searchParams.set('type', 'airline');
|
||||||
stops: number;
|
|
||||||
cabinClass: CabinClass;
|
|
||||||
fareRule: FareRule;
|
|
||||||
refundable: boolean;
|
|
||||||
basePrice: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const genRandomFlights = (): Flight[] => {
|
// forward all relevant search params to the API
|
||||||
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
||||||
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
params.forEach(param => {
|
||||||
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
const val = searchParams.get(param);
|
||||||
|
if (val) url.searchParams.set(param, val);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export default function AirlineProducts() {
|
const res = await fetch(url.toString());
|
||||||
const flights = genRandomFlights();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
<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">
|
<div className="space-y-4">
|
||||||
{flights.map((f) => (
|
{flights.map((f) => (
|
||||||
<AirlineCard key={f.id} flight={f} />
|
<AirlineCard key={f.id} flight={f} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAllExperiments } from '@/lib/sessionStore';
|
import { createClient } from '@/utils/supabase/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const exps = getAllExperiments();
|
const cookieStore = await cookies();
|
||||||
return NextResponse.json({ experiments: exps });
|
const supabase = createClient(cookieStore);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
task:tasks(*)
|
||||||
|
`)
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return NextResponse.json({ experiment: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
task:tasks(*)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ experiments: data || [] });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('experiments list error:', err);
|
console.error('experiments list error:', err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -13,3 +43,44 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const supabase = createClient(cookieStore);
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const { subject_name, xp_human_only, xp_market_mode, xp_task_id } = body;
|
||||||
|
|
||||||
|
if (!subject_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'subject_name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.insert([{
|
||||||
|
subject_name,
|
||||||
|
xp_human_only: xp_human_only ?? false,
|
||||||
|
xp_market_mode: xp_market_mode || null,
|
||||||
|
xp_task_id: xp_task_id || null,
|
||||||
|
}])
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
task:tasks(*)
|
||||||
|
`)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ experiment: data });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('experiment creation error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
58
web/src/app/api/admin/tasks/route.ts
Normal file
58
web/src/app/api/admin/tasks/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/utils/supabase/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const supabase = createClient(cookieStore);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ tasks: data || [] });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('tasks fetch error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const supabase = createClient(cookieStore);
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const { task_name, task_description, task_def_of_done } = body;
|
||||||
|
|
||||||
|
if (!task_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'task_name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.insert([{ task_name, task_description, task_def_of_done }])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ task: data });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('task creation error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,17 +13,6 @@ export async function GET(req: NextRequest) {
|
|||||||
const experimentId = searchParams.get('experimentId');
|
const experimentId = searchParams.get('experimentId');
|
||||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||||
|
|
||||||
// log in dev
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[pricing-api]', {
|
|
||||||
productId,
|
|
||||||
sessionId,
|
|
||||||
experimentId,
|
|
||||||
storeMode,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'productId is required' },
|
{ error: 'productId is required' },
|
||||||
@@ -34,11 +23,46 @@ export async function GET(req: NextRequest) {
|
|||||||
// stub: call external pricing provider (random for now)
|
// stub: call external pricing provider (random for now)
|
||||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||||
const price = Math.round(basePrice * 100) / 100;
|
const price = Math.round(basePrice * 100) / 100;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// log price to kafka for elasticity computation
|
||||||
|
if (sessionId) {
|
||||||
|
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||||
|
try {
|
||||||
|
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId,
|
||||||
|
price,
|
||||||
|
sessionId,
|
||||||
|
experimentId: experimentId || undefined,
|
||||||
|
storeMode,
|
||||||
|
ts: timestamp,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[price-log-error]', err);
|
||||||
|
// don't fail the pricing request if logging fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// log in dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[pricing-api]', {
|
||||||
|
productId,
|
||||||
|
sessionId,
|
||||||
|
experimentId,
|
||||||
|
storeMode,
|
||||||
|
price,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response: PricingResponse = {
|
const response: PricingResponse = {
|
||||||
price,
|
price,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
cachedAt: new Date().toISOString(),
|
cachedAt: timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
|
|||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { getSession, createSession } from '@/lib/sessionStore';
|
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
|
||||||
|
|
||||||
const COOKIE_NAME = 'phantom_session_id';
|
const COOKIE_NAME = 'phantom_session_id';
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// check for existing session cookie
|
|
||||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
@@ -18,13 +17,11 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// mint new session id
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
createSession(sessionId);
|
createSession(sessionId);
|
||||||
|
|
||||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
||||||
|
|
||||||
// set httpOnly cookie with security flags
|
|
||||||
res.cookies.set({
|
res.cookies.set({
|
||||||
name: COOKIE_NAME,
|
name: COOKIE_NAME,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
@@ -32,7 +29,7 @@ export async function GET(req: NextRequest) {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: isProd,
|
secure: isProd,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@@ -44,3 +41,52 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { experimentId } = body;
|
||||||
|
|
||||||
|
if (!experimentId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'experimentId is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionId = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = randomUUID();
|
||||||
|
createSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExperiment(sessionId, experimentId);
|
||||||
|
|
||||||
|
const res = NextResponse.json({
|
||||||
|
sessionId,
|
||||||
|
experimentId,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!req.cookies.get(COOKIE_NAME)) {
|
||||||
|
res.cookies.set({
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: sessionId,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('session update error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ 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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||||
|
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
||||||
|
|
||||||
interface Hotel {
|
function RoomsList() {
|
||||||
id: string;
|
const searchParams = useSearchParams();
|
||||||
name: string;
|
const [rooms, setRooms] = useState<Hotel[]>([]);
|
||||||
roomType: string;
|
const [loading, setLoading] = useState(true);
|
||||||
checkIn: string;
|
const [error, setError] = useState<string | null>(null);
|
||||||
checkOut: string;
|
|
||||||
amenities: string[];
|
useEffect(() => {
|
||||||
refundable: boolean;
|
const fetchRooms = async () => {
|
||||||
pricePerNight: number;
|
try {
|
||||||
nights: number;
|
const url = new URL('/api/products', window.location.origin);
|
||||||
|
url.searchParams.set('type', 'hotel');
|
||||||
|
|
||||||
|
// 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() {
|
export default function HotelProducts() {
|
||||||
const hotels = genRandomHotels();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||||
<div className="space-y-4">
|
<RoomsList />
|
||||||
{hotels.map((h) => (
|
</Suspense>
|
||||||
<HotelCard key={h.id} hotel={h} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { TrackingProvider } from "@/components/TrackingProvider";
|
import { TrackingProvider } from "@/components/TrackingProvider";
|
||||||
|
import { CartProvider } from "@/contexts/CartContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -28,7 +29,9 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<CartProvider>
|
||||||
<TrackingProvider>{children}</TrackingProvider>
|
<TrackingProvider>{children}</TrackingProvider>
|
||||||
|
</CartProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
93
web/src/app/start-task/page.tsx
Normal file
93
web/src/app/start-task/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const StartTaskContent = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState<'loading' | 'error' | 'redirecting'>('loading');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const uuid = searchParams.get('uuid');
|
||||||
|
|
||||||
|
if (!uuid) {
|
||||||
|
setError('no experiment UUID provided');
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateAndStore = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/experiments?id=${uuid}`);
|
||||||
|
if (!res.ok) throw new Error('experiment not found');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const exp = data.experiment;
|
||||||
|
|
||||||
|
if (!exp) throw new Error('invalid experiment UUID');
|
||||||
|
|
||||||
|
localStorage.setItem('phantom_experiment_id', uuid);
|
||||||
|
|
||||||
|
await fetch('/api/session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ experimentId: uuid }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus('redirecting');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/");
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'failed to start task');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateAndStore();
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||||
|
<div className="text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-200 border-t-zinc-900 dark:border-zinc-800 dark:border-t-zinc-100 mx-auto" />
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400">validating browser...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'redirecting' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 text-4xl">✓</div>
|
||||||
|
<p className="text-zinc-900 dark:text-zinc-100 font-medium">website loaded</p>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">redirecting to page...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-6 dark:bg-red-950">
|
||||||
|
<p className="text-red-900 dark:text-red-100 font-medium">error</p>
|
||||||
|
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StartTaskPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400">loading...</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<StartTaskContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
web/src/components/admin/ExperimentForm.tsx
Normal file
118
web/src/components/admin/ExperimentForm.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type ExperimentFormProps = {
|
||||||
|
selectedTaskId?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExperimentForm = ({ selectedTaskId, onSuccess }: ExperimentFormProps) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
subject_name: '',
|
||||||
|
xp_human_only: false,
|
||||||
|
xp_market_mode: 'hotel' as 'hotel' | 'airline',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/experiments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...form,
|
||||||
|
xp_task_id: selectedTaskId || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'creation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm({ subject_name: '', xp_human_only: false, xp_market_mode: 'hotel' });
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Create Experiment
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
subject name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.subject_name}
|
||||||
|
onChange={(e) => setForm({ ...form, subject_name: e.target.value })}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||||
|
placeholder="e.g., baseline_dynamic_pricing_v1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
market mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.xp_market_mode}
|
||||||
|
onChange={(e) => setForm({ ...form, xp_market_mode: e.target.value as 'hotel' | 'airline' })}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||||
|
>
|
||||||
|
<option value="hotel">hotel</option>
|
||||||
|
<option value="airline">airline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="human-only"
|
||||||
|
checked={form.xp_human_only}
|
||||||
|
onChange={(e) => setForm({ ...form, xp_human_only: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
|
/>
|
||||||
|
<label htmlFor="human-only" className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
human participants only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTaskId && (
|
||||||
|
<div className="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
task selected: <span className="font-mono text-xs">{selectedTaskId.slice(0, 8)}...</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
{loading ? 'creating experiment...' : 'create experiment'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
web/src/components/admin/TaskManager.tsx
Normal file
178
web/src/components/admin/TaskManager.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type Task = {
|
||||||
|
id: string;
|
||||||
|
task_name: string;
|
||||||
|
task_description: string;
|
||||||
|
task_def_of_done: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskManagerProps = {
|
||||||
|
onTaskSelect?: (taskId: string) => void;
|
||||||
|
selectedTaskId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaskManager = ({ onTaskSelect, selectedTaskId }: TaskManagerProps) => {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
task_name: '',
|
||||||
|
task_description: '',
|
||||||
|
task_def_of_done: '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/tasks');
|
||||||
|
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTasks(data.tasks || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'creation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm({ task_name: '', task_description: '', task_def_of_done: '' });
|
||||||
|
setShowForm(false);
|
||||||
|
await fetchTasks();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Tasks
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-black dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{showForm ? 'cancel' : 'new task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
task name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.task_name}
|
||||||
|
onChange={(e) => setForm({ ...form, task_name: e.target.value })}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||||
|
placeholder="e.g., Book cheapest flight to Paris"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.task_description}
|
||||||
|
onChange={(e) => setForm({ ...form, task_description: e.target.value })}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||||
|
placeholder="User should find and book the cheapest available flight..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
definition of done
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.task_def_of_done}
|
||||||
|
onChange={(e) => setForm({ ...form, task_def_of_done: e.target.value })}
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||||
|
placeholder="Booking is completed and confirmation page is shown"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
{loading ? 'creating...' : 'create task'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
no tasks yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onTaskSelect?.(task.id)}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedTaskId === task.id
|
||||||
|
? 'border-zinc-900 bg-zinc-50 dark:border-zinc-100 dark:bg-zinc-900'
|
||||||
|
: 'border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{task.task_name}
|
||||||
|
</h3>
|
||||||
|
{task.task_description && (
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{task.task_description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{task.task_def_of_done && (
|
||||||
|
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
|
||||||
|
done: {task.task_def_of_done}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
|
import type { Flight } from '@/lib/airline-utils';
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -11,32 +12,17 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
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 }) {
|
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||||
const durationRef = useHoverTracking({
|
const durationRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: flight.duration },
|
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: 'price' },
|
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -44,7 +30,9 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
cabinClass: flight.cabinClass,
|
cabinClass: flight.cabinClass,
|
||||||
fareRule: flight.fareRule,
|
fareRule: flight.fareRule,
|
||||||
price: flight.basePrice,
|
price: flight.basePrice,
|
||||||
|
dateIndex: flight.dateIndex,
|
||||||
});
|
});
|
||||||
|
window.location.href = `/airline/products/${flight.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function AirlineHero() {
|
export default function AirlineHero() {
|
||||||
|
const router = useRouter();
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
@@ -28,7 +31,23 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (departDate) {
|
||||||
|
const daysOffset = dateToDaysFromToday(departDate);
|
||||||
|
params.set('dateIndex', daysOffset.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin) params.set('origin', origin);
|
||||||
|
if (destination) params.set('destination', destination);
|
||||||
|
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
||||||
|
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
||||||
|
|
||||||
|
params.set('adults', passengers.adults.toString());
|
||||||
|
params.set('children', passengers.children.toString());
|
||||||
|
params.set('infants', passengers.infants.toString());
|
||||||
|
|
||||||
|
router.push(`/airline/products?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
|
import type { Hotel } from '@/lib/hotel-utils';
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -11,18 +12,6 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
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 AmenityIcon = ({ name }: { name: string }) => {
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
wifi: 'Wi-Fi',
|
wifi: 'Wi-Fi',
|
||||||
@@ -39,13 +28,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
const titleRef = useHoverTracking({
|
const titleRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: hotel.name },
|
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: 'price' },
|
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -53,7 +42,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
roomType: hotel.roomType,
|
roomType: hotel.roomType,
|
||||||
price: hotel.pricePerNight,
|
price: hotel.pricePerNight,
|
||||||
nights: hotel.nights,
|
nights: hotel.nights,
|
||||||
|
dateIndex: hotel.dateIndex,
|
||||||
});
|
});
|
||||||
|
window.location.href = `/hotel/products/${hotel.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Hotel } from '@/lib/hotel-utils';
|
||||||
|
|
||||||
|
interface HotelDetailsProps {
|
||||||
|
product: Hotel;
|
||||||
|
onAddToCart: () => void;
|
||||||
|
addedToCart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||||
|
return (
|
||||||
|
<div className="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';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
|
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
||||||
|
|
||||||
const LocationIcon = () => (
|
const LocationIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -11,14 +13,25 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function HotelHero() {
|
export default function HotelHero() {
|
||||||
|
const router = useRouter();
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [checkIn, setCheckIn] = useState('');
|
const [checkIn, setCheckIn] = useState('');
|
||||||
const [checkOut, setCheckOut] = useState('');
|
|
||||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log({ destination, checkIn, checkOut, guests });
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (checkIn) {
|
||||||
|
const daysOffset = dateToDaysFromToday(checkIn);
|
||||||
|
params.set('dateIndex', daysOffset.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination) params.set('destination', destination);
|
||||||
|
params.set('adults', guests.adults.toString());
|
||||||
|
params.set('rooms', guests.rooms.toString());
|
||||||
|
|
||||||
|
router.push(`/hotel/products?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,16 +39,16 @@ export default function HotelHero() {
|
|||||||
<div className="w-full max-w-4xl px-4">
|
<div className="w-full max-w-4xl px-4">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
Find your perfect stay
|
Find your perfect room
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg">
|
<p className="text-lg">
|
||||||
Search hotels, compare prices, and book with confidence
|
Search rooms, compare prices, and book with confidence
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="search-form">
|
<form onSubmit={handleSearch} className="search-form">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div className="sm:col-span-2">
|
<div>
|
||||||
<Label htmlFor="destination">Where to?</Label>
|
<Label htmlFor="destination">Where to?</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -49,7 +62,7 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkIn">Check-in</Label>
|
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="checkIn"
|
id="checkIn"
|
||||||
value={checkIn}
|
value={checkIn}
|
||||||
@@ -59,43 +72,27 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkOut">Check-out</Label>
|
<Label htmlFor="guests">Guests</Label>
|
||||||
<DateInput
|
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
||||||
id="checkOut"
|
|
||||||
value={checkOut}
|
|
||||||
onChange={(e) => setCheckOut(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-4">
|
|
||||||
<Label htmlFor="guests">Guests & Rooms</Label>
|
|
||||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
|
||||||
<DropdownCounter
|
<DropdownCounter
|
||||||
label="Adults"
|
label="Adults"
|
||||||
value={guests.adults}
|
value={guests.adults}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||||
/>
|
/>
|
||||||
<DropdownCounter
|
|
||||||
label="Rooms"
|
|
||||||
value={guests.rooms}
|
|
||||||
min={1}
|
|
||||||
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-4">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<Button type="submit" fullWidth>
|
<Button type="submit" fullWidth>
|
||||||
Search Hotels
|
Search Rooms
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import '@/lib/experiments' // ensure experiments lib is loaded
|
import '@/lib/experiments'
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
|
|
||||||
const fetchSessionId = async (): Promise<string> => {
|
const fetchSessionId = async (): Promise<string> => {
|
||||||
@@ -21,10 +21,14 @@ const track = async (ev: {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
|
const experimentId = localStorage.getItem('phantom_experiment_id');
|
||||||
await fetch('/api/ingest', {
|
await fetch('/api/ingest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(ev),
|
body: JSON.stringify({
|
||||||
|
...ev,
|
||||||
|
experimentId: experimentId || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('track failed:', err);
|
console.error('track failed:', err);
|
||||||
|
|||||||
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';
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@ export function proxy(req: NextRequest) {
|
|||||||
pathname.startsWith('/admin') ||
|
pathname.startsWith('/admin') ||
|
||||||
pathname.startsWith('/_next') ||
|
pathname.startsWith('/_next') ||
|
||||||
pathname.startsWith('/static') ||
|
pathname.startsWith('/static') ||
|
||||||
|
pathname.startsWith('/start-task') ||
|
||||||
|
pathname.startsWith('/cart') ||
|
||||||
pathname.includes('.')
|
pathname.includes('.')
|
||||||
// TODO: add robots.txt and sitemap.xml if needed here
|
// TODO: add robots.txt and sitemap.xml if needed here
|
||||||
) {
|
) {
|
||||||
|
|||||||
10
web/src/utils/supabase/client.ts
Normal file
10
web/src/utils/supabase/client.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const createClient = () =>
|
||||||
|
createBrowserClient(
|
||||||
|
supabaseUrl!,
|
||||||
|
supabaseKey!,
|
||||||
|
);
|
||||||
37
web/src/utils/supabase/middleware.ts
Normal file
37
web/src/utils/supabase/middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const createClient = (request: NextRequest) => {
|
||||||
|
// Create an unmodified response
|
||||||
|
let supabaseResponse = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
supabaseUrl!,
|
||||||
|
supabaseKey!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||||
|
supabaseResponse = NextResponse.next({
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return supabaseResponse
|
||||||
|
};
|
||||||
27
web/src/utils/supabase/server.ts
Normal file
27
web/src/utils/supabase/server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const createClient = (cookieStore: ReadonlyRequestCookies) => {
|
||||||
|
return createServerClient(
|
||||||
|
supabaseUrl!,
|
||||||
|
supabaseKey!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
||||||
|
} catch {
|
||||||
|
// `setAll` called from Server Component - ignored if middleware handles session refresh
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user