mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
3 Commits
first-pric
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aed8e7311 | ||
|
|
90ba7588cc | ||
|
|
df3082cff5 |
21
.env.example
21
.env.example
@@ -1,18 +1,5 @@
|
|||||||
# Network configuration
|
HOSTNAME=localhost
|
||||||
HOSTNAME=localhost # hostname for service discovery across docker network
|
|
||||||
|
|
||||||
# Application configuration
|
# PORTS
|
||||||
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
KAFKA_PORT=9092
|
||||||
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
|
REDIS_PORT=6377
|
||||||
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
|
|
||||||
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
|
|
||||||
|
|
||||||
# Backend service
|
|
||||||
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
|
|
||||||
|
|
||||||
# Service ports - used by docker-compose and service communication
|
|
||||||
BACKEND_PORT=5000 # backend server port for kafka ingestion API
|
|
||||||
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
|
|
||||||
KAFKA_PORT=9092 # kafka broker port for event streaming
|
|
||||||
REDIS_PORT=6377 # redis port for worker queue and caching
|
|
||||||
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
|
|
||||||
|
|||||||
30
.github/workflows/pytest.yml
vendored
30
.github/workflows/pytest.yml
vendored
@@ -1,30 +0,0 @@
|
|||||||
name: Run Tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'experiments/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '.github/workflows/pytest.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'experiments/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '.github/workflows/pytest.yml'
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.13'
|
|
||||||
cache: 'pip'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m venv .venv
|
|
||||||
.venv/bin/pip install --upgrade pip
|
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
- name: Run tests
|
|
||||||
run: .venv/bin/pytest -v
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,8 +1,2 @@
|
|||||||
**/.env
|
**/.env
|
||||||
**/.venv
|
**/.venv
|
||||||
**/__pycache__
|
|
||||||
**/.ipynb_checkpoints/
|
|
||||||
**/.virtual_documents/
|
|
||||||
**/session_*.svg
|
|
||||||
**/*graph.svg
|
|
||||||
paper/src/bib/auto
|
|
||||||
|
|||||||
15
Makefile
15
Makefile
@@ -4,10 +4,6 @@ BUILDDIR := build
|
|||||||
TEX := main.tex
|
TEX := main.tex
|
||||||
JOBNAME := main
|
JOBNAME := main
|
||||||
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
||||||
VENV := .venv
|
|
||||||
PYTHON := $(VENV)/bin/python
|
|
||||||
PIP := $(VENV)/bin/pip
|
|
||||||
PYTEST := $(VENV)/bin/pytest
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
@@ -39,14 +35,5 @@ clean:
|
|||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
rm -rf paper/$(BUILDDIR)/*
|
||||||
|
|
||||||
$(VENV):
|
|
||||||
python3 -m venv $(VENV)
|
|
||||||
$(PIP) install --upgrade pip
|
|
||||||
|
|
||||||
install: $(VENV)
|
.PHONY: all pdf clean watch run.webapp
|
||||||
$(PIP) install -r requirements.txt
|
|
||||||
|
|
||||||
test: $(VENV)
|
|
||||||
$(PYTEST) -v
|
|
||||||
|
|
||||||
.PHONY: all pdf clean watch run.webapp install test
|
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
|
||||||
- https://phantom-hotel.vercel.app/
|
|
||||||
- https://phantom-airline.vercel.app/
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
# boilerplate code
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, Any
|
|
||||||
import uvicorn
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
|
||||||
from kafka.admin import NewTopic
|
|
||||||
from kafka.errors import TopicAlreadyExistsError
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from supabase import create_client, Client
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
# kafka producer - lazy init
|
|
||||||
_producer: Optional[KafkaProducer] = None
|
|
||||||
|
|
||||||
# supabase client - lazy init
|
|
||||||
_supabase: Optional[Client] = None
|
|
||||||
|
|
||||||
def get_supabase() -> Client:
|
|
||||||
global _supabase
|
|
||||||
if _supabase is None:
|
|
||||||
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
|
||||||
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
|
||||||
if not url or not key:
|
|
||||||
raise ValueError("Supabase credentials not configured")
|
|
||||||
_supabase = create_client(url, key)
|
|
||||||
return _supabase
|
|
||||||
|
|
||||||
def get_producer() -> KafkaProducer:
|
|
||||||
global _producer
|
|
||||||
if _producer is None:
|
|
||||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
|
||||||
broker = f'{host}:{port}' if port else host
|
|
||||||
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
|
||||||
_producer = KafkaProducer(
|
|
||||||
bootstrap_servers=[broker],
|
|
||||||
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
|
||||||
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
|
||||||
acks=1,
|
|
||||||
retries=3,
|
|
||||||
max_in_flight_requests_per_connection=5,
|
|
||||||
request_timeout_ms=30000,
|
|
||||||
api_version_auto_timeout_ms=10000,
|
|
||||||
max_block_ms=5000, # don't block send() for more than 5s
|
|
||||||
)
|
|
||||||
print(f"[KAFKA_INIT] Producer created successfully")
|
|
||||||
return _producer
|
|
||||||
|
|
||||||
class EventPayload(BaseModel):
|
|
||||||
sessionId: str
|
|
||||||
experimentId: Optional[str] = None
|
|
||||||
eventName: str
|
|
||||||
page: str
|
|
||||||
productId: Optional[str] = None
|
|
||||||
metadata: Optional[dict[str, Any]] = None
|
|
||||||
storeMode: str
|
|
||||||
userAgent: 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(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""create kafka topics on startup"""
|
|
||||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
|
||||||
broker = f'{host}:{port}'
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"[STARTUP] Creating Kafka topics on {broker}")
|
|
||||||
admin = KafkaAdminClient(
|
|
||||||
bootstrap_servers=[broker],
|
|
||||||
request_timeout_ms=10000,
|
|
||||||
)
|
|
||||||
|
|
||||||
topics = [
|
|
||||||
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)
|
|
||||||
print(f"[STARTUP] Topics created successfully")
|
|
||||||
admin.close()
|
|
||||||
except TopicAlreadyExistsError:
|
|
||||||
print(f"[STARTUP] Topics already exist, skipping creation")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[STARTUP] Failed to create topics: {e}")
|
|
||||||
print(f"[STARTUP] Will rely on auto-creation on first message")
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
kafka_status = "unknown"
|
|
||||||
try:
|
|
||||||
producer = get_producer()
|
|
||||||
# attempt to get cluster metadata to verify connection
|
|
||||||
producer.bootstrap_connected()
|
|
||||||
kafka_status = "connected"
|
|
||||||
except Exception as e:
|
|
||||||
kafka_status = f"error: {str(e)}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "healthy",
|
|
||||||
"kafka": kafka_status,
|
|
||||||
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/kafka/ingest")
|
|
||||||
async def ingest_logs(event: EventPayload):
|
|
||||||
try:
|
|
||||||
if not event.ts:
|
|
||||||
event.ts = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
producer = get_producer()
|
|
||||||
future = producer.send(
|
|
||||||
'user-interactions',
|
|
||||||
key=event.sessionId,
|
|
||||||
value=event.model_dump()
|
|
||||||
)
|
|
||||||
# add callback for error logging but don't block
|
|
||||||
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
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")
|
|
||||||
def dump_logs(
|
|
||||||
topic: str = 'user-interactions',
|
|
||||||
last_n: Optional[int] = None,
|
|
||||||
t_start: Optional[str] = None,
|
|
||||||
t_end: Optional[str] = None
|
|
||||||
):
|
|
||||||
"""dump all messages from specified kafka topic
|
|
||||||
|
|
||||||
params:
|
|
||||||
topic: kafka topic to dump (default: user-interactions)
|
|
||||||
last_n: return only last n messages (default: all)
|
|
||||||
t_start: filter by start timestamp iso format
|
|
||||||
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')
|
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
|
||||||
broker = f'{host}:{port}'
|
|
||||||
|
|
||||||
try:
|
|
||||||
consumer = KafkaConsumer(
|
|
||||||
topic,
|
|
||||||
bootstrap_servers=[broker],
|
|
||||||
auto_offset_reset='earliest',
|
|
||||||
enable_auto_commit=False,
|
|
||||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
|
||||||
consumer_timeout_ms=5000
|
|
||||||
)
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for msg in consumer:
|
|
||||||
events.append(msg.value)
|
|
||||||
|
|
||||||
consumer.close()
|
|
||||||
|
|
||||||
# apply filters
|
|
||||||
if t_start or t_end:
|
|
||||||
filtered = []
|
|
||||||
for e in events:
|
|
||||||
ts = e.get('ts')
|
|
||||||
if ts:
|
|
||||||
if t_start and ts < t_start:
|
|
||||||
continue
|
|
||||||
if t_end and ts > t_end:
|
|
||||||
continue
|
|
||||||
filtered.append(e)
|
|
||||||
events = filtered
|
|
||||||
|
|
||||||
if last_n and last_n > 0:
|
|
||||||
events = events[-last_n:]
|
|
||||||
|
|
||||||
return {"success": True, "count": len(events), "data": events}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[DUMP_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/products/{product_id}")
|
|
||||||
async def get_product_by_id(product_id: str):
|
|
||||||
"""fetch single product by id from either hotel_products or airline_products"""
|
|
||||||
try:
|
|
||||||
supabase = get_supabase()
|
|
||||||
|
|
||||||
# try hotel_products first
|
|
||||||
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
|
|
||||||
if response.data and len(response.data) > 0:
|
|
||||||
return {"success": True, "data": response.data[0]}
|
|
||||||
|
|
||||||
# try airline_products
|
|
||||||
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
|
|
||||||
if response.data and len(response.data) > 0:
|
|
||||||
return {"success": True, "data": response.data[0]}
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRODUCT_BY_ID_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/products/type/{product_type}")
|
|
||||||
async def get_products(
|
|
||||||
product_type: str,
|
|
||||||
dateIndex: Optional[int] = None,
|
|
||||||
origin: Optional[str] = None,
|
|
||||||
destination: Optional[str] = None,
|
|
||||||
tripType: Optional[str] = None,
|
|
||||||
adults: Optional[int] = None,
|
|
||||||
children: Optional[int] = None,
|
|
||||||
infants: Optional[int] = None,
|
|
||||||
rooms: Optional[int] = None
|
|
||||||
):
|
|
||||||
"""fetch products from supabase based on type (hotel or airline)
|
|
||||||
|
|
||||||
params:
|
|
||||||
product_type: either 'hotel' or 'airline'
|
|
||||||
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
|
||||||
origin: (airline) departure airport code
|
|
||||||
destination: (airline/hotel) arrival airport or hotel location
|
|
||||||
tripType: (airline) roundtrip, oneway, multicity
|
|
||||||
adults, children, infants: passenger counts
|
|
||||||
rooms: (hotel) number of rooms
|
|
||||||
"""
|
|
||||||
if product_type not in ['hotel', 'airline']:
|
|
||||||
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
|
|
||||||
|
|
||||||
try:
|
|
||||||
supabase = get_supabase()
|
|
||||||
table = f'{product_type}_products'
|
|
||||||
|
|
||||||
query = supabase.table(table).select('*')
|
|
||||||
|
|
||||||
# filter by exact date_index if provided
|
|
||||||
if dateIndex is not None:
|
|
||||||
query = query.eq('date_index', dateIndex)
|
|
||||||
|
|
||||||
response = query.execute()
|
|
||||||
results = response.data
|
|
||||||
|
|
||||||
# apply in-memory filters based on metadata for airline products
|
|
||||||
if product_type == 'airline' and results:
|
|
||||||
filtered = []
|
|
||||||
for product in results:
|
|
||||||
metadata = product.get('metadata', {})
|
|
||||||
|
|
||||||
# filter by origin airport
|
|
||||||
if origin:
|
|
||||||
dep = metadata.get('departure', {})
|
|
||||||
if dep.get('airport') != origin:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# filter by destination airport
|
|
||||||
if destination:
|
|
||||||
arr = metadata.get('arrival', {})
|
|
||||||
if arr.get('airport') != destination:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# passenger count validation (ensure total capacity)
|
|
||||||
if adults is not None or children is not None or infants is not None:
|
|
||||||
total_pax = (adults or 0) + (children or 0) + (infants or 0)
|
|
||||||
avail = product.get('availability', 0)
|
|
||||||
if avail < total_pax:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(product)
|
|
||||||
|
|
||||||
results = filtered
|
|
||||||
|
|
||||||
# apply in-memory filters for hotel products
|
|
||||||
elif product_type == 'hotel' and results:
|
|
||||||
filtered = []
|
|
||||||
for product in results:
|
|
||||||
metadata = product.get('metadata', {})
|
|
||||||
|
|
||||||
# filter by occupancy capacity
|
|
||||||
if adults is not None:
|
|
||||||
max_occ = metadata.get('max_occupancy', 2)
|
|
||||||
if max_occ < adults:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# filter by room availability
|
|
||||||
if rooms is not None:
|
|
||||||
avail = product.get('availability', 0)
|
|
||||||
if avail < rooms:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(product)
|
|
||||||
|
|
||||||
results = filtered
|
|
||||||
|
|
||||||
return {"success": True, "count": len(results), "data": results}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRODUCTS_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
|
||||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
fastapi==0.104.1
|
|
||||||
uvicorn[standard]==0.24.0
|
|
||||||
kafka-python==2.0.2
|
|
||||||
pydantic==2.5.0
|
|
||||||
python-dotenv==1.0.0
|
|
||||||
supabase==2.9.1
|
|
||||||
@@ -1,21 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
|
||||||
container_name: "PHANTOM-backend"
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/backend.Dockerfile
|
|
||||||
ports:
|
|
||||||
- "${BACKEND_PORT:-5000}:5000"
|
|
||||||
environment:
|
|
||||||
- KAFKA_HOST=kafka
|
|
||||||
- KAFKA_PORT=29092
|
|
||||||
- BACKEND_PORT=5000
|
|
||||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
|
||||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
|
||||||
depends_on:
|
|
||||||
- kafka
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: "PHANTOM-redis"
|
container_name: "PHANTOM-redis"
|
||||||
build:
|
build:
|
||||||
@@ -26,7 +9,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- phantom_redis_data:/data
|
- phantom_redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
zookeeper:
|
zookeeper:
|
||||||
container_name: "PHANTOM-zookeeper"
|
container_name: "PHANTOM-zookeeper"
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY backend/server/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY backend/server/app.py .
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Agentic behavior runner for PHANTOM research platform."""
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from .base import Agent as BaseAgent
|
|
||||||
from browser_use import Browser, Agent, ChatOpenAI
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
class AgentTypes(str, Enum):
|
|
||||||
GENERIC_BROWSER_USE_AGENT = "generic_browser_use_agent"
|
|
||||||
|
|
||||||
def _build_prompt(goal : str, environment_url : str) -> str:
|
|
||||||
#TODO: Improve prompt engineering here and experiment with
|
|
||||||
return f"""You are an autonomous agent tasked with achieving the following goal: {goal}
|
|
||||||
You have access to a web browser to interact with the environment at {environment_url}.
|
|
||||||
Use the browser to navigate, gather information, and perform actions necessary to accomplish your goal.
|
|
||||||
Be thorough and ensure you complete the task fully."""
|
|
||||||
|
|
||||||
class GenericBrowserUseAgent(BaseAgent):
|
|
||||||
def __init__(self,
|
|
||||||
goal: str,
|
|
||||||
url: str = "http://localhost:3000",
|
|
||||||
timeout: int = 300,
|
|
||||||
llm_model: str = "gpt-5-mini",
|
|
||||||
headless: bool = True):
|
|
||||||
super().__init__(goal, url, timeout)
|
|
||||||
self.llm_model = ChatOpenAI(model=llm_model)
|
|
||||||
self.browser = Browser(headless=headless)
|
|
||||||
self.agent = Agent(task=_build_prompt(goal, url),
|
|
||||||
llm=self.llm_model,
|
|
||||||
browser=self.browser)
|
|
||||||
async def act(self) -> str:
|
|
||||||
self.result = await self.agent.run()
|
|
||||||
# https://github.com/browser-use/browser-use/blob/main/browser_use/agent/views.py#L301
|
|
||||||
return self.result.final_result()
|
|
||||||
|
|
||||||
def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
|
||||||
if agent_type == AgentTypes.GENERIC_BROWSER_USE_AGENT:
|
|
||||||
return GenericBrowserUseAgent(**kwargs)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown agent type: {agent_type}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import asyncio
|
|
||||||
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/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
|
||||||
timeout=300)
|
|
||||||
R=asyncio.run(agent.act())
|
|
||||||
print(R)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
class Agent(ABC):
|
|
||||||
"""Base interface for browser automation agents"""
|
|
||||||
|
|
||||||
def __init__(self, goal: str, url: str = "http://localhost:3000", timeout: int = 300):
|
|
||||||
self.goal = goal
|
|
||||||
self.url = url
|
|
||||||
self.timeout = timeout
|
|
||||||
self.result: Optional[str] = None
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def act(self) -> str:
|
|
||||||
"""Execute goal and return result text"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def final_result(self) -> Optional[str]:
|
|
||||||
return self.result
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
from experiments.agents.agent import get_agent, AgentTypes
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_init():
|
|
||||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="test", url="http://example.com", timeout=100)
|
|
||||||
assert agent.goal == "test"
|
|
||||||
assert agent.url == "http://example.com"
|
|
||||||
assert agent.timeout == 100
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_agent():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_agent("invalid", goal="test")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
|
|
||||||
async def test_agent_execution():
|
|
||||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="get page title", url="https://example.com", timeout=60)
|
|
||||||
|
|
||||||
result = await agent.act()
|
|
||||||
assert result
|
|
||||||
assert agent.final_result()
|
|
||||||
assert agent.final_result().history[-1].result[-1].is_done == True
|
|
||||||
assert isinstance(result, str)
|
|
||||||
assert "example" in result.lower()
|
|
||||||
assert len(result) > 0
|
|
||||||
721
experiments/data_export.ipynb
Normal file
721
experiments/data_export.ipynb
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 98,
|
||||||
|
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"True"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 98,
|
||||||
|
"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": 86,
|
||||||
|
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||||
|
"RangeIndex: 141 entries, 0 to 140\n",
|
||||||
|
"Data columns (total 10 columns):\n",
|
||||||
|
" # Column Non-Null Count Dtype \n",
|
||||||
|
"--- ------ -------------- ----- \n",
|
||||||
|
" 0 sessionId 141 non-null object \n",
|
||||||
|
" 1 eventType 141 non-null object \n",
|
||||||
|
" 2 ts 141 non-null int64 \n",
|
||||||
|
" 3 targetEl 14 non-null object \n",
|
||||||
|
" 4 targetUrl 1 non-null object \n",
|
||||||
|
" 5 metadata_path 141 non-null object \n",
|
||||||
|
" 6 metadata_referrer 6 non-null object \n",
|
||||||
|
" 7 metadata_x 14 non-null float64\n",
|
||||||
|
" 8 metadata_y 14 non-null float64\n",
|
||||||
|
" 9 metadata_scrollY 121 non-null float64\n",
|
||||||
|
"dtypes: float64(3), int64(1), object(6)\n",
|
||||||
|
"memory usage: 11.1+ 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": 87,
|
||||||
|
"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>eventType</th>\n",
|
||||||
|
" <th>ts</th>\n",
|
||||||
|
" <th>targetEl</th>\n",
|
||||||
|
" <th>targetUrl</th>\n",
|
||||||
|
" <th>metadata_path</th>\n",
|
||||||
|
" <th>metadata_referrer</th>\n",
|
||||||
|
" <th>metadata_x</th>\n",
|
||||||
|
" <th>metadata_y</th>\n",
|
||||||
|
" <th>metadata_scrollY</th>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </thead>\n",
|
||||||
|
" <tbody>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>0</th>\n",
|
||||||
|
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||||
|
" <td>pageview</td>\n",
|
||||||
|
" <td>1761226211163</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>1</th>\n",
|
||||||
|
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761226218090</td>\n",
|
||||||
|
" <td>MAIN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>815.0</td>\n",
|
||||||
|
" <td>331.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>2</th>\n",
|
||||||
|
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761226220890</td>\n",
|
||||||
|
" <td>MAIN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>1129.0</td>\n",
|
||||||
|
" <td>605.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>3</th>\n",
|
||||||
|
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761226225801</td>\n",
|
||||||
|
" <td>DIV</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>532.0</td>\n",
|
||||||
|
" <td>545.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>4</th>\n",
|
||||||
|
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761226229364</td>\n",
|
||||||
|
" <td>DIV</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>481.0</td>\n",
|
||||||
|
" <td>399.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>5</th>\n",
|
||||||
|
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||||
|
" <td>pageview</td>\n",
|
||||||
|
" <td>1761227236426</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>6</th>\n",
|
||||||
|
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761227239328</td>\n",
|
||||||
|
" <td>DIV</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>202.0</td>\n",
|
||||||
|
" <td>351.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>7</th>\n",
|
||||||
|
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761227244783</td>\n",
|
||||||
|
" <td>A</td>\n",
|
||||||
|
" <td>https://vercel.com/new?utm_source=create-next-...</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>377.0</td>\n",
|
||||||
|
" <td>723.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>8</th>\n",
|
||||||
|
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||||
|
" <td>pageview</td>\n",
|
||||||
|
" <td>1761828261783</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>9</th>\n",
|
||||||
|
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1761828266484</td>\n",
|
||||||
|
" <td>H1</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>527.0</td>\n",
|
||||||
|
" <td>169.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>10</th>\n",
|
||||||
|
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||||
|
" <td>scroll</td>\n",
|
||||||
|
" <td>1761828270314</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>51.666668</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>11</th>\n",
|
||||||
|
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||||
|
" <td>scroll</td>\n",
|
||||||
|
" <td>1761828270328</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>50.000000</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>12</th>\n",
|
||||||
|
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||||
|
" <td>scroll</td>\n",
|
||||||
|
" <td>1761828270336</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>49.166668</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </tbody>\n",
|
||||||
|
"</table>\n",
|
||||||
|
"</div>"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
" sessionId eventType ts targetEl \\\n",
|
||||||
|
"0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n",
|
||||||
|
"1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n",
|
||||||
|
"2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n",
|
||||||
|
"3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n",
|
||||||
|
"4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n",
|
||||||
|
"5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n",
|
||||||
|
"6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n",
|
||||||
|
"7 1761227236286-e7mphcvw6t click 1761227244783 A \n",
|
||||||
|
"8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n",
|
||||||
|
"9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n",
|
||||||
|
"10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n",
|
||||||
|
"11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n",
|
||||||
|
"12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n",
|
||||||
|
"\n",
|
||||||
|
" targetUrl metadata_path \\\n",
|
||||||
|
"0 NaN / \n",
|
||||||
|
"1 NaN / \n",
|
||||||
|
"2 NaN / \n",
|
||||||
|
"3 NaN / \n",
|
||||||
|
"4 NaN / \n",
|
||||||
|
"5 NaN / \n",
|
||||||
|
"6 NaN / \n",
|
||||||
|
"7 https://vercel.com/new?utm_source=create-next-... / \n",
|
||||||
|
"8 NaN / \n",
|
||||||
|
"9 NaN / \n",
|
||||||
|
"10 NaN / \n",
|
||||||
|
"11 NaN / \n",
|
||||||
|
"12 NaN / \n",
|
||||||
|
"\n",
|
||||||
|
" metadata_referrer metadata_x metadata_y metadata_scrollY \n",
|
||||||
|
"0 NaN NaN NaN \n",
|
||||||
|
"1 NaN 815.0 331.0 NaN \n",
|
||||||
|
"2 NaN 1129.0 605.0 NaN \n",
|
||||||
|
"3 NaN 532.0 545.0 NaN \n",
|
||||||
|
"4 NaN 481.0 399.0 NaN \n",
|
||||||
|
"5 NaN NaN NaN \n",
|
||||||
|
"6 NaN 202.0 351.0 NaN \n",
|
||||||
|
"7 NaN 377.0 723.0 NaN \n",
|
||||||
|
"8 NaN NaN NaN \n",
|
||||||
|
"9 NaN 527.0 169.0 NaN \n",
|
||||||
|
"10 NaN NaN NaN 51.666668 \n",
|
||||||
|
"11 NaN NaN NaN 50.000000 \n",
|
||||||
|
"12 NaN NaN NaN 49.166668 "
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 87,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"df.groupby('sessionId').head()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 88,
|
||||||
|
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"['1761225843899-qaiwwwyj2o',\n",
|
||||||
|
" '1761828056433-0gz7aboz86h',\n",
|
||||||
|
" '1761227236286-e7mphcvw6t']"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 88,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"sessions = list(set(df['sessionId'])); sessions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 89,
|
||||||
|
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# map sessions to experiments"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 101,
|
||||||
|
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||||
|
" df = df.dropna(subset=['eventType'])\n",
|
||||||
|
" events = df['eventType'].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": 107,
|
||||||
|
"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": 108,
|
||||||
|
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"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=\"228pt\" height=\"124pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 228.00 124.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 119.83)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||||
|
"<!-- pageview -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>pageview</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>click</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- pageview->click -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>pageview->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click->click -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>click->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7fd404165c70>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0. 1.]\n",
|
||||||
|
" [0. 1.]]\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=\"358pt\" height=\"132pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 358.00 132.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 128.41)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-128.41 354.16,-128.41 354.16,4 -4,4\"/>\n",
|
||||||
|
"<!-- pageview -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>pageview</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- pageview->pageview -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>pageview->pageview</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M30.86,-87.29C31.64,-98.6 36.22,-107.16 44.58,-107.16 49.94,-107.16 53.74,-103.65 55.99,-98.15\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"59.33,-99.28 57.99,-88.77 52.48,-97.82 59.33,-99.28\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-111.11\" font-family=\"Times,serif\" font-size=\"14.00\">0.2</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>click</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- pageview->click -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>pageview->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.8</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click->pageview -->\n",
|
||||||
|
"<g id=\"edge3\" class=\"edge\">\n",
|
||||||
|
"<title>click->pageview</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M150.74,-29.52C143.93,-26.96 136.67,-24.68 129.66,-23.33 119.02,-21.28 107.71,-22.06 96.96,-24.24\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"96.33,-20.79 87.47,-26.6 98.02,-27.59 96.33,-20.79\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-27.28\" font-family=\"Times,serif\" font-size=\"14.00\">0.3</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click->click -->\n",
|
||||||
|
"<g id=\"edge4\" class=\"edge\">\n",
|
||||||
|
"<title>click->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.6</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- scroll -->\n",
|
||||||
|
"<g id=\"node3\" class=\"node\">\n",
|
||||||
|
"<title>scroll</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"314.16\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">scroll</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click->scroll -->\n",
|
||||||
|
"<g id=\"edge5\" class=\"edge\">\n",
|
||||||
|
"<title>click->scroll</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M220.12,-44.58C234.44,-44.58 251.18,-44.58 266.47,-44.58\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"266.31,-48.08 276.31,-44.58 266.31,-41.08 266.31,-48.08\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"248.91\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.1</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- scroll->scroll -->\n",
|
||||||
|
"<g id=\"edge6\" class=\"edge\">\n",
|
||||||
|
"<title>scroll->scroll</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M301.93,-78.86C302.06,-89.86 306.13,-98.58 314.16,-98.58 319.18,-98.58 322.66,-95.17 324.59,-89.93\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"327.99,-90.78 326.15,-80.35 321.08,-89.66 327.99,-90.78\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7fd406e21a90>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0.25 0.75 0. ]\n",
|
||||||
|
" [0.28571429 0.57142857 0.14285714]\n",
|
||||||
|
" [0. 0.00826446 0.99173554]]\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=\"228pt\" height=\"124pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 228.00 124.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 119.83)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||||
|
"<!-- pageview -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>pageview</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>click</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- pageview->click -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>pageview->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- click->click -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>click->click</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7fd4041662b0>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0. 1.]\n",
|
||||||
|
" [0. 1.]]\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"def explore_session(session_id: str):\n",
|
||||||
|
" subset = df[df['sessionId'] == session_id] # not .where(...)\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))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4d278c2d-406e-4dc0-b219-5f7b236e852b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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',
|
|
||||||
]
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
import json
|
|
||||||
import numpy as np
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from typing import Tuple, List, Dict
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if not data.get('success') or not data.get('data'):
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
df = pd.DataFrame(data['data'])
|
|
||||||
if self.topic == 'user-interactions':
|
|
||||||
if 'metadata' in df.columns: # explode metadata col json
|
|
||||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
# remape dateIndex
|
|
||||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
if df.empty or 'experimentId' not in df.columns:
|
|
||||||
return df
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class EventTitleAugmenter(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
|
||||||
# we want metadata schema to create product specific event names
|
|
||||||
|
|
||||||
# only create price buckets if we have enough unique prices
|
|
||||||
if df["metadata_price"].notnull().sum() > 0:
|
|
||||||
try:
|
|
||||||
price_buckets = pd.qcut(
|
|
||||||
df["metadata_price"],
|
|
||||||
q=N_PRICE_BUCKETS,
|
|
||||||
labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)],
|
|
||||||
duplicates='drop' # handle duplicate bin edges
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# fallback: if still not enough unique values, use cut with fixed ranges or just use raw price
|
|
||||||
price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "")
|
|
||||||
else:
|
|
||||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
|
||||||
|
|
||||||
# metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name
|
|
||||||
# TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page
|
|
||||||
df["metadata_schema"] = np.where(
|
|
||||||
df["productId"].notnull() & df["metadata_price"].notnull(),
|
|
||||||
"_" + df["productId"].astype(str) + "@" + price_buckets.astype(str),
|
|
||||||
""
|
|
||||||
)
|
|
||||||
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_shared_data(interactions_df: pd.DataFrame,
|
|
||||||
price_logs_df: pd.DataFrame,
|
|
||||||
window_size: str = '30s',
|
|
||||||
ts_col: str = 'ts') -> Tuple[List[Dict], List[Dict]]:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple of (interaction_chunks, price_chunks) where each is list of dicts:
|
|
||||||
{
|
|
||||||
'window_start': timestamp,
|
|
||||||
'window_end': timestamp,
|
|
||||||
'data': dataframe for this window
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if interactions_df.empty and price_logs_df.empty:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# convert timestamps to datetime
|
|
||||||
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 not price_logs_df.empty:
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(price_logs_df[ts_col]):
|
|
||||||
price_logs_df[ts_col] = pd.to_datetime(price_logs_df[ts_col])
|
|
||||||
|
|
||||||
# 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,158 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
|
|
||||||
def build_transition_prob_matrix(df: pd.DataFrame):
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
events = df['eventName'].tolist()
|
|
||||||
labels = pd.Index(events).unique().tolist()
|
|
||||||
idx = {e:i for i,e in enumerate(labels)}
|
|
||||||
M = np.zeros((len(labels), len(labels)), dtype=float)
|
|
||||||
for a, b in zip(events, events[1:]):
|
|
||||||
M[idx[a], idx[b]] += 1
|
|
||||||
row_sums = M.sum(axis=1, keepdims=True)
|
|
||||||
with np.errstate(divide='ignore', invalid='ignore'):
|
|
||||||
P = np.divide(M, row_sums, where=row_sums>0) # row-normalized
|
|
||||||
return P, labels
|
|
||||||
|
|
||||||
# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b
|
|
||||||
from graphviz import Digraph
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
def _as_prob_df(matrix, labels=None):
|
|
||||||
"""Return a square DataFrame with index=columns=labels."""
|
|
||||||
if isinstance(matrix, pd.DataFrame):
|
|
||||||
# Ensure square and aligned
|
|
||||||
assert (matrix.index == matrix.columns).all(), "Index/columns must match."
|
|
||||||
return matrix
|
|
||||||
matrix = np.asarray(matrix, dtype=float)
|
|
||||||
assert matrix.shape[0] == matrix.shape[1], "Matrix must be square."
|
|
||||||
if labels is None:
|
|
||||||
raise ValueError("labels are required when matrix is not a DataFrame")
|
|
||||||
assert len(labels) == matrix.shape[0], "labels length must match matrix size."
|
|
||||||
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
|
|
||||||
|
|
||||||
def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):
|
|
||||||
"""Build weighted edges > threshold."""
|
|
||||||
edges = []
|
|
||||||
for src in P.index:
|
|
||||||
for dst in P.columns:
|
|
||||||
w = float(P.loc[src, dst])
|
|
||||||
if w > threshold:
|
|
||||||
edges.append((str(src), str(dst), f"{w:.{round_digits}f}"))
|
|
||||||
return edges
|
|
||||||
|
|
||||||
def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt="svg", view=False):
|
|
||||||
"""
|
|
||||||
fname: output file stem (no extension)
|
|
||||||
matrix: NumPy array or pandas DataFrame of transition PROBABILITIES
|
|
||||||
ls_index: ordered labels (required if matrix is not a DataFrame)
|
|
||||||
threshold: hide edges with weight <= threshold
|
|
||||||
fmt: 'svg'|'png'|'pdf' etc.
|
|
||||||
view: open after rendering
|
|
||||||
"""
|
|
||||||
P = _as_prob_df(matrix, labels=ls_index)
|
|
||||||
edges = _df_to_edgelist(P, threshold=threshold)
|
|
||||||
|
|
||||||
g = Digraph(format=fmt)
|
|
||||||
g.attr(rankdir="LR", size="30")
|
|
||||||
g.attr("node", shape="circle")
|
|
||||||
|
|
||||||
# ensure isolated nodes appear
|
|
||||||
for node in P.index:
|
|
||||||
g.node(str(node), width="1", height="1")
|
|
||||||
|
|
||||||
for src, dst, label in edges:
|
|
||||||
g.edge(src, dst, label=label)
|
|
||||||
|
|
||||||
g.render(fname, view=view, cleanup=True)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.P_ = None
|
|
||||||
self.labels_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
P, labels = build_transition_prob_matrix(X)
|
|
||||||
self.P_ = P
|
|
||||||
self.labels_ = labels
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
return self.P_, self.labels_
|
|
||||||
|
|
||||||
def render(self, fname: str, fmt="svg", view=False):
|
|
||||||
if self.P_ is None or self.labels_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
self.P_,
|
|
||||||
ls_index=self.labels_,
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionTransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0, session_col='sessionId'):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.session_col = session_col
|
|
||||||
self.session_matrices_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
if self.session_col not in X.columns:
|
|
||||||
raise ValueError(f"Column '{self.session_col}' not found in DataFrame")
|
|
||||||
|
|
||||||
session_matrices = {}
|
|
||||||
for session_id, grp in X.groupby(self.session_col):
|
|
||||||
if len(grp) > 1: # need at least 2 events for transitions
|
|
||||||
P, labels = build_transition_prob_matrix(grp)
|
|
||||||
session_matrices[session_id] = {'matrix': P, 'labels': labels}
|
|
||||||
|
|
||||||
self.session_matrices_ = session_matrices
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return pd.Series(self.session_matrices_)
|
|
||||||
|
|
||||||
def render_session(self, session_id: str, fname: str, fmt="svg", view=False):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
if session_id not in self.session_matrices_:
|
|
||||||
raise ValueError(f"Session '{session_id}' not found in fitted data.")
|
|
||||||
|
|
||||||
sess_data = self.session_matrices_[session_id]
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
sess_data['matrix'],
|
|
||||||
ls_index=sess_data['labels'],
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Example usage
|
|
||||||
data = {
|
|
||||||
'eventName': [
|
|
||||||
'A', 'B', 'A', 'C', 'B', 'A', 'A', 'C', 'B', 'C',
|
|
||||||
'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
|
|
||||||
transformer = TransitionProbMatrixTransformer(threshold=0.1)
|
|
||||||
transformer.fit(df)
|
|
||||||
P, labels = transformer.transform(None)
|
|
||||||
|
|
||||||
print("Transition Probability Matrix:")
|
|
||||||
print(pd.DataFrame(P, index=labels, columns=labels))
|
|
||||||
|
|
||||||
# Render the graph
|
|
||||||
transformer.render("transition_graph", fmt="svg", view=False)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from sklearn.pipeline import Pipeline
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
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 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__":
|
|
||||||
# fetch both datasets
|
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
|
||||||
pricing_data = price_data_pipeline.fit_transform(None)
|
|
||||||
if interaction_data.empty or pricing_data.empty:
|
|
||||||
print("Insufficient data for elasticity computation"); exit(0)
|
|
||||||
# compute elasticity via unified pipeline
|
|
||||||
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)
|
|
||||||
|
|
||||||
if elasticity_results is not None and not elasticity_results.empty:
|
|
||||||
print(elasticity_results.to_string(index=False))
|
|
||||||
else:
|
|
||||||
print("\nInsufficient data for elasticity computation")
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -16,15 +16,11 @@ mkdir -p "$(dirname "$OUTPUT_FILE")"
|
|||||||
add_file() {
|
add_file() {
|
||||||
local filepath="$1"
|
local filepath="$1"
|
||||||
local relpath="${filepath#$PROJECT_ROOT/}"
|
local relpath="${filepath#$PROJECT_ROOT/}"
|
||||||
local escaped_path="${relpath//_/\\_}"
|
|
||||||
|
|
||||||
# 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{${relpath}}" >> "$OUTPUT_FILE"
|
||||||
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
echo "\\begin{lstlisting}[caption={${relpath}}]" >> "$OUTPUT_FILE"
|
||||||
# Convert to ASCII: transliterate what's possible, drop the rest
|
cat "$filepath" >> "$OUTPUT_FILE"
|
||||||
# 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"
|
||||||
|
|||||||
@@ -20,10 +20,7 @@
|
|||||||
commentstyle=\color{green!60!black},
|
commentstyle=\color{green!60!black},
|
||||||
stringstyle=\color{red},
|
stringstyle=\color{red},
|
||||||
showstringspaces=false,
|
showstringspaces=false,
|
||||||
captionpos=b,
|
captionpos=b
|
||||||
inputencoding=utf8,
|
|
||||||
extendedchars=true,
|
|
||||||
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
% Use biblatex instead of natbib (acmart default)
|
% Use biblatex instead of natbib (acmart default)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
testpaths = experiments
|
|
||||||
python_files = test*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
asyncio_mode = auto
|
|
||||||
asyncio_default_fixture_loop_scope = function
|
|
||||||
@@ -5,9 +5,3 @@ jupyter
|
|||||||
ipykernel
|
ipykernel
|
||||||
matplotlib
|
matplotlib
|
||||||
graphviz
|
graphviz
|
||||||
browser-use
|
|
||||||
pytest
|
|
||||||
pytest-asyncio
|
|
||||||
uv
|
|
||||||
scikit-learn
|
|
||||||
supabase
|
|
||||||
|
|||||||
@@ -12,86 +12,3 @@ The webapp should serve under the / route the landing page which for both platfo
|
|||||||
- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition.
|
- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition.
|
||||||
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
|
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
|
||||||
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
|
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
|
||||||
|
|
||||||
## How to Run
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# install deps
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# set store mode (hotel or airline)
|
|
||||||
export STORE_MODE=hotel
|
|
||||||
|
|
||||||
# run dev server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Server runs on `http://localhost:3000`
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default | Example |
|
|
||||||
|----------|-------------|---------|---------|
|
|
||||||
| `HOSTNAME` | Server hostname | `localhost` | `localhost` |
|
|
||||||
| `STORE_MODE` | Mode switch for platform | `hotel` | `hotel` or `airline` |
|
|
||||||
| `NEXT_PUBLIC_API_BASE` | Public API base URL | `http://localhost:3000` | `http://localhost:3000` |
|
|
||||||
| `NEXT_PUBLIC_APP_ENV` | Application environment | `dev` | `dev`, `prod` |
|
|
||||||
| `NEXT_PUBLIC_HOVER_THRESHOLD` | Hover dwell threshold (ms) | `1200` | `1200` |
|
|
||||||
| `BACKEND_URL` | Backend service URL | `http://localhost:5000` | `http://localhost:5000` |
|
|
||||||
|
|
||||||
## Routes
|
|
||||||
|
|
||||||
### Public Pages
|
|
||||||
- `/` — Landing page (mode-aware root)
|
|
||||||
- `/hotel` — Hotel mode landing
|
|
||||||
- `/hotel/products` — Hotel catalog
|
|
||||||
- `/airline` — Airline mode landing
|
|
||||||
- `/airline/products` — Flight catalog
|
|
||||||
- `/admin/experiments` — Experiment management UI
|
|
||||||
|
|
||||||
### API Routes
|
|
||||||
- `GET /api/session` — Fetch or create session, sets httpOnly cookie
|
|
||||||
- `GET /api/pricing?productId=X&sessionId=Y&experimentId=Z` — Get product price from provider
|
|
||||||
- `POST /api/ingest` — Ingest event to Kafka via backend
|
|
||||||
- `GET /api/admin/experiments` — List all experiments
|
|
||||||
- `POST /api/admin/experiments/start` — Start new experiment for session
|
|
||||||
- `POST /api/admin/experiments/stop` — Stop experiment by ID
|
|
||||||
|
|
||||||
## Event Catalog
|
|
||||||
|
|
||||||
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
|
|
||||||
|
|
||||||
| Event Name | Category | Payload Example |
|
|
||||||
|------------|----------|-----------------|
|
|
||||||
| `session_start` | Session | `{ sessionId, experimentId?, storeMode, ts, page, eventName, userAgent? }` |
|
|
||||||
| `page_view` | Navigation | `{ sessionId, experimentId?, storeMode, ts, page: "/hotel", eventName: "page_view" }` |
|
|
||||||
| `view_item_page` | Discovery | `{ sessionId, storeMode, ts, page: "/hotel/products", productId: "H001", eventName: "view_item_page" }` |
|
|
||||||
| `learn_more_about_item` | Discovery | `{ sessionId, storeMode, ts, page, productId, eventName: "learn_more_about_item" }` |
|
|
||||||
| `add_item_to_cart` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "add_item_to_cart" }` |
|
|
||||||
| `remove_item` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "remove_item" }` |
|
|
||||||
| `checkout_start` | Cart | `{ sessionId, storeMode, ts, page, eventName: "checkout_start" }` |
|
|
||||||
| `purchase_complete` | Cart | `{ sessionId, storeMode, ts, page, eventName: "purchase_complete", metadata?: { total: 500 } }` |
|
|
||||||
| `search` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "search", metadata: { query: "paris" } }` |
|
|
||||||
| `filter_for_date` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_date", metadata: { from: "2025-01-15", to: "2025-01-20" } }` |
|
|
||||||
| `filter_for_amenities` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_amenities", metadata: { amenities: ["wifi", "pool"] } }` |
|
|
||||||
| `filter_for_price` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_price", metadata: { min: 100, max: 500 } }` |
|
|
||||||
| `sort_change` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "sort_change", metadata: { sort: "price_asc" } }` |
|
|
||||||
| `hover_over_title` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_title", metadata: { duration: 1500 } }` |
|
|
||||||
| `hover_over_paragraph` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_paragraph", metadata: { duration: 2000 } }` |
|
|
||||||
| `hover_over_link` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_link", metadata: { href: "/hotel/products" } }` |
|
|
||||||
| `hover_over_button` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_button", metadata: { label: "Book Now" } }` |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Route Groups
|
|
||||||
- `(hotel)` — Hotel mode pages
|
|
||||||
- `(airline)` — Airline mode pages
|
|
||||||
- `api/*` — API routes (session, pricing, ingest, admin)
|
|
||||||
|
|
||||||
### Middleware Flow
|
|
||||||
1. Request arrives at Next.js
|
|
||||||
2. Session middleware checks for `phantom_session_id` cookie
|
|
||||||
3. If missing, `/api/session` mints new session + sets cookie
|
|
||||||
4. Store mode (`STORE_MODE` env) determines rendered page variant
|
|
||||||
5. Client-side components fetch pricing via `/api/pricing`
|
|
||||||
6. User interactions emit events to `/api/ingest` → Kafka
|
|
||||||
|
|||||||
162
web/package-lock.json
generated
162
web/package-lock.json
generated
@@ -8,12 +8,10 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"kafkajs": "^2.2.4",
|
||||||
"@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"
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -659,97 +657,6 @@
|
|||||||
"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",
|
||||||
@@ -1034,17 +941,12 @@
|
|||||||
"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",
|
||||||
@@ -1065,15 +967,6 @@
|
|||||||
"@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",
|
||||||
@@ -1100,15 +993,6 @@
|
|||||||
"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",
|
||||||
@@ -1157,6 +1041,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kafkajs": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
@@ -1721,37 +1614,8 @@
|
|||||||
"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": {
|
|
||||||
"version": "4.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
|
||||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,10 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"kafkajs": "^2.2.4",
|
||||||
"@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"
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { TaskManager } from '@/components/admin/TaskManager';
|
|
||||||
import { ExperimentForm } from '@/components/admin/ExperimentForm';
|
|
||||||
|
|
||||||
type Experiment = {
|
|
||||||
id: string;
|
|
||||||
subject_name: string;
|
|
||||||
xp_human_only: boolean;
|
|
||||||
xp_market_mode: string;
|
|
||||||
created_at: string;
|
|
||||||
task?: {
|
|
||||||
id: string;
|
|
||||||
task_name: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ExperimentsAdmin() {
|
|
||||||
const [exps, setExps] = useState<Experiment[]>([]);
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
|
|
||||||
const fetchExps = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/experiments');
|
|
||||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
setExps(data.experiments || []);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchExps();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleExperimentCreated = async () => {
|
|
||||||
setShowForm(false);
|
|
||||||
setSelectedTaskId(undefined);
|
|
||||||
await fetchExps();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
|
||||||
Experiment Management
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
configure tasks and run experiments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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">
|
|
||||||
{error}
|
|
||||||
</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">
|
|
||||||
<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">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
subject
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
mode
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
human
|
|
||||||
</th>
|
|
||||||
<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
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
link
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
||||||
{exps.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
no experiments yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
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
|
|
||||||
key={exp.id}
|
|
||||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
{exp.subject_name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<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">
|
|
||||||
{exp.xp_market_mode || 'none'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{exp.xp_human_only ? (
|
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">
|
|
||||||
yes
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-zinc-500">no</span>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import '@/styles/airline.css';
|
|
||||||
|
|
||||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
|
||||||
return <div data-mode="airline">{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
|
||||||
|
|
||||||
export default function AirlineHome() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<AirlineHero />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'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,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
|
||||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
|
||||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
function FlightsList() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [flights, setFlights] = useState<Flight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFlights = async () => {
|
|
||||||
try {
|
|
||||||
const url = new URL('/api/products', window.location.origin);
|
|
||||||
url.searchParams.set('type', 'airline');
|
|
||||||
|
|
||||||
// forward all relevant search params to the API
|
|
||||||
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
|
||||||
params.forEach(param => {
|
|
||||||
const val = searchParams.get(param);
|
|
||||||
if (val) url.searchParams.set(param, val);
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
const transformed = json.data.map((p: AirlineProduct) => transformProduct(p));
|
|
||||||
setFlights(transformed);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
|
||||||
console.error('[FETCH_ERROR]', e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchFlights();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{flights.map((f) => (
|
|
||||||
<AirlineCard key={f.id} flight={f} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AirlineProducts() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
|
||||||
<FlightsList />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { createClient } from '@/utils/supabase/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
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) {
|
|
||||||
console.error('experiments list 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 { 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { createExperiment, getSession } from '@/lib/sessionStore';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const { sessionId } = body;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'sessionId required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify session exists
|
|
||||||
const session = getSession(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'session not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate and create experiment
|
|
||||||
const experimentId = randomUUID();
|
|
||||||
const exp = createExperiment(sessionId, experimentId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
experimentId: exp.id,
|
|
||||||
sessionId,
|
|
||||||
status: exp.status,
|
|
||||||
createdAt: exp.createdAt,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('experiment start error:', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const { experimentId } = body;
|
|
||||||
|
|
||||||
if (!experimentId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'experimentId required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify experiment exists
|
|
||||||
const existing = getExperiment(experimentId);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'experiment not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop the experiment
|
|
||||||
const exp = stopExperimentById(experimentId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
experimentId: exp!.id,
|
|
||||||
status: exp!.status,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('experiment stop error:', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import type { EventBase } from '@/lib/events';
|
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
|
|
||||||
const storeMode = process.env.STORE_MODE || 'hotel';
|
|
||||||
const userAgent = req.headers.get('user-agent') || undefined;
|
|
||||||
|
|
||||||
const event: EventBase = {
|
|
||||||
...body,
|
|
||||||
storeMode,
|
|
||||||
userAgent,
|
|
||||||
ts: body.ts || new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(event),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Backend returned ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
|
|
||||||
console.log('[ingest]', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[ingest error]', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
interface PricingResponse {
|
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
cachedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const productId = searchParams.get('productId');
|
|
||||||
const sessionId = searchParams.get('sessionId');
|
|
||||||
const experimentId = searchParams.get('experimentId');
|
|
||||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
|
||||||
|
|
||||||
if (!productId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'productId is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// stub: call external pricing provider (random for now)
|
|
||||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
|
||||||
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 = {
|
|
||||||
price,
|
|
||||||
currency: 'EUR',
|
|
||||||
cachedAt: timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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,92 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'phantom_session_id';
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
|
||||||
|
|
||||||
if (existingSession) {
|
|
||||||
const sessionData = getSession(existingSession);
|
|
||||||
return NextResponse.json({
|
|
||||||
sessionId: existingSession,
|
|
||||||
experimentId: sessionData?.experimentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
createSession(sessionId);
|
|
||||||
|
|
||||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
|
||||||
|
|
||||||
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 error:', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
web/src/app/api/track/route.ts
Normal file
33
web/src/app/api/track/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { sendInteractionEvent } from '@/lib/kafka';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
|
||||||
|
|
||||||
|
if (!sessionId || !eventType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'sessionId and eventType required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendInteractionEvent({
|
||||||
|
sessionId,
|
||||||
|
eventType,
|
||||||
|
targetEl,
|
||||||
|
targetUrl,
|
||||||
|
metadata,
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('track error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -23,7 +21,6 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
@@ -69,9 +66,7 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -91,19 +86,13 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
background-color: #007aff;
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
background-color: #0051d5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-spacing {
|
.section-spacing {
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import '@/styles/hotel.css';
|
|
||||||
|
|
||||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
|
||||||
return <div data-mode="hotel">{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import HotelHero from '@/components/feats/hotel/HotelHero';
|
|
||||||
|
|
||||||
export default function HotelHome() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<HotelHero />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'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,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
|
||||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
|
||||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
function RoomsList() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [rooms, setRooms] = useState<Hotel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRooms = async () => {
|
|
||||||
try {
|
|
||||||
const url = new URL('/api/products', window.location.origin);
|
|
||||||
url.searchParams.set('type', 'hotel');
|
|
||||||
|
|
||||||
// forward all relevant search params to the API
|
|
||||||
const params = ['dateIndex', 'destination', 'adults', 'rooms'];
|
|
||||||
params.forEach(param => {
|
|
||||||
const val = searchParams.get(param);
|
|
||||||
if (val) url.searchParams.set(param, val);
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
const transformed = json.data.map((p: HotelProduct) => transformProduct(p));
|
|
||||||
setRooms(transformed);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
|
||||||
console.error('[FETCH_ERROR]', e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchRooms();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Rooms</h1>
|
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{rooms.map((r) => (
|
|
||||||
<HotelCard key={r.id} hotel={r} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HotelProducts() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
|
||||||
<RoomsList />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ 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",
|
||||||
@@ -29,9 +28,7 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'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,75 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
import type { Flight } from '@/lib/airline-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
|
||||||
|
|
||||||
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 AirlineCard({ flight }: { flight: Flight }) {
|
|
||||||
const durationRef = useHoverTracking({
|
|
||||||
eventName: 'hover_over_title',
|
|
||||||
productId: flight.id,
|
|
||||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
|
||||||
});
|
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
|
||||||
eventName: 'hover_over_paragraph',
|
|
||||||
productId: flight.id,
|
|
||||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
dispatchInteraction('view_item_page', flight.id, {
|
|
||||||
cabinClass: flight.cabinClass,
|
|
||||||
fareRule: flight.fareRule,
|
|
||||||
price: flight.basePrice,
|
|
||||||
dateIndex: flight.dateIndex,
|
|
||||||
});
|
|
||||||
window.location.href = `/airline/products/${flight.id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flight-card cursor-pointer"
|
|
||||||
onClick={handleCardClick}
|
|
||||||
>
|
|
||||||
<div className="flight-timing">
|
|
||||||
<div className="flight-time">{flight.departure.time}</div>
|
|
||||||
<div className="flight-airport">{flight.departure.airport}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flight-route">
|
|
||||||
<div ref={durationRef} className="flight-duration">{flight.duration}</div>
|
|
||||||
<div className="flight-stops">
|
|
||||||
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flight-timing">
|
|
||||||
<div className="flight-time">{flight.arrival.time}</div>
|
|
||||||
<div className="flight-airport">{flight.arrival.airport}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flight-pricing">
|
|
||||||
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
|
|
||||||
{flight.refundable && (
|
|
||||||
<div className="badge-value text-xs mb-2">Refundable</div>
|
|
||||||
)}
|
|
||||||
<div ref={priceRef}>
|
|
||||||
<PriceDisplay
|
|
||||||
productId={flight.id}
|
|
||||||
className="fare-price"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
'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,175 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
|
||||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
|
||||||
|
|
||||||
const PlaneIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LocationIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function AirlineHero() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
|
||||||
const [origin, setOrigin] = useState('');
|
|
||||||
const [destination, setDestination] = useState('');
|
|
||||||
const [departDate, setDepartDate] = useState('');
|
|
||||||
const [returnDate, setReturnDate] = useState('');
|
|
||||||
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
|
||||||
<div className="w-full max-w-5xl px-4">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
|
||||||
Book flights at the best prices
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg">
|
|
||||||
Compare hundreds of airlines and find the perfect flight for your journey
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="search-form">
|
|
||||||
<form onSubmit={handleSearch}>
|
|
||||||
<div className="mb-6">
|
|
||||||
<RadioGroup
|
|
||||||
name="tripType"
|
|
||||||
value={tripType}
|
|
||||||
onChange={setTripType}
|
|
||||||
options={[
|
|
||||||
{ value: 'roundtrip', label: 'Round-trip' },
|
|
||||||
{ value: 'oneway', label: 'One-way' },
|
|
||||||
{ value: 'multicity', label: 'Multi-city' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="origin">From</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="origin"
|
|
||||||
value={origin}
|
|
||||||
onChange={(e) => setOrigin(e.target.value)}
|
|
||||||
placeholder="Airport or city"
|
|
||||||
icon={<PlaneIcon />}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="destination">To</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="destination"
|
|
||||||
value={destination}
|
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
|
||||||
placeholder="Airport or city"
|
|
||||||
icon={<LocationIcon />}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="departDate">Departure</Label>
|
|
||||||
<DateInput
|
|
||||||
id="departDate"
|
|
||||||
value={departDate}
|
|
||||||
onChange={(e) => setDepartDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="returnDate">Return</Label>
|
|
||||||
{tripType === 'roundtrip' ? (
|
|
||||||
<DateInput
|
|
||||||
id="returnDate"
|
|
||||||
value={returnDate}
|
|
||||||
onChange={(e) => setReturnDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DateInput id="returnDate" disabled />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
|
||||||
<div className="sm:col-span-1 lg:col-span-1">
|
|
||||||
<Label htmlFor="passengers">Passengers</Label>
|
|
||||||
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
|
|
||||||
<DropdownCounter
|
|
||||||
label="Adults"
|
|
||||||
sublabel="12+ years"
|
|
||||||
value={passengers.adults}
|
|
||||||
min={1}
|
|
||||||
onChange={(v) => setPassengers({ ...passengers, adults: v })}
|
|
||||||
/>
|
|
||||||
<DropdownCounter
|
|
||||||
label="Children"
|
|
||||||
sublabel="2-11 years"
|
|
||||||
value={passengers.children}
|
|
||||||
onChange={(v) => setPassengers({ ...passengers, children: v })}
|
|
||||||
/>
|
|
||||||
<DropdownCounter
|
|
||||||
label="Infants"
|
|
||||||
sublabel="Under 2"
|
|
||||||
value={passengers.infants}
|
|
||||||
onChange={(v) => setPassengers({ ...passengers, infants: v })}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button type="submit" fullWidth>
|
|
||||||
Search Flights
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
|
||||||
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
|
||||||
const e = new CustomEvent('definedInteraction', {
|
|
||||||
detail: { eventName, productId, metadata },
|
|
||||||
});
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AmenityIcon = ({ name }: { name: string }) => {
|
|
||||||
const iconMap: Record<string, string> = {
|
|
||||||
wifi: 'Wi-Fi',
|
|
||||||
pool: 'Pool',
|
|
||||||
gym: 'Gym',
|
|
||||||
parking: 'Parking',
|
|
||||||
breakfast: 'Breakfast',
|
|
||||||
spa: 'Spa',
|
|
||||||
};
|
|
||||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|
||||||
const titleRef = useHoverTracking({
|
|
||||||
eventName: 'hover_over_title',
|
|
||||||
productId: hotel.id,
|
|
||||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
|
||||||
});
|
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
|
||||||
eventName: 'hover_over_paragraph',
|
|
||||||
productId: hotel.id,
|
|
||||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
dispatchInteraction('view_item_page', hotel.id, {
|
|
||||||
roomType: hotel.roomType,
|
|
||||||
price: hotel.pricePerNight,
|
|
||||||
nights: hotel.nights,
|
|
||||||
dateIndex: hotel.dateIndex,
|
|
||||||
});
|
|
||||||
window.location.href = `/hotel/products/${hotel.id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="hotel-card cursor-pointer"
|
|
||||||
onClick={handleCardClick}
|
|
||||||
>
|
|
||||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
|
||||||
<span className="text-gray-400 text-sm">Image</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hotel-info">
|
|
||||||
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
|
||||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
|
||||||
{hotel.checkIn} - {hotel.checkOut}
|
|
||||||
</div>
|
|
||||||
<div className="hotel-features">
|
|
||||||
{hotel.amenities.map((a) => (
|
|
||||||
<AmenityIcon key={a} name={a} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hotel.refundable && (
|
|
||||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hotel-pricing">
|
|
||||||
<div ref={priceRef}>
|
|
||||||
<PriceDisplay
|
|
||||||
productId={hotel.id}
|
|
||||||
className="price-wrapper"
|
|
||||||
perNight
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
|
||||||
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'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,100 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
|
||||||
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
const LocationIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function HotelHero() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [destination, setDestination] = useState('');
|
|
||||||
const [checkIn, setCheckIn] = useState('');
|
|
||||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
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 (
|
|
||||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
|
||||||
<div className="w-full max-w-4xl px-4">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
|
||||||
Find your perfect room
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg">
|
|
||||||
Search rooms, compare prices, and book with confidence
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="search-form">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="destination">Where to?</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="destination"
|
|
||||||
value={destination}
|
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
|
||||||
placeholder="City, hotel, or landmark"
|
|
||||||
icon={<LocationIcon />}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
|
||||||
<DateInput
|
|
||||||
id="checkIn"
|
|
||||||
value={checkIn}
|
|
||||||
onChange={(e) => setCheckIn(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="guests">Guests</Label>
|
|
||||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
|
||||||
<DropdownCounter
|
|
||||||
label="Adults"
|
|
||||||
value={guests.adults}
|
|
||||||
min={1}
|
|
||||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
|
||||||
<Button type="submit" fullWidth>
|
|
||||||
Search Rooms
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
|
||||||
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
type BtnVariant = 'primary' | 'secondary';
|
|
||||||
|
|
||||||
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: BtnVariant;
|
|
||||||
children: ReactNode;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
|
|
||||||
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
|
|
||||||
const widthClass = fullWidth ? 'w-full' : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { InputHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
|
||||||
|
|
||||||
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
|
||||||
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ReactNode, useState, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface DropdownProps {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dropdown({ label, children }: DropdownProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={ref}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="input-field flex justify-between items-center w-full"
|
|
||||||
>
|
|
||||||
<span>{label}</span>
|
|
||||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CounterProps {
|
|
||||||
label: string;
|
|
||||||
sublabel?: string;
|
|
||||||
value: number;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
onChange: (val: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">{label}</span>
|
|
||||||
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(Math.max(min, value - 1))}
|
|
||||||
disabled={value <= min}
|
|
||||||
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(Math.min(max, value + 1))}
|
|
||||||
disabled={value >= max}
|
|
||||||
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { InputHTMLAttributes, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
icon?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Input({ icon, className = '', style, ...props }: InpProps) {
|
|
||||||
const padClass = icon ? 'pl-10' : '';
|
|
||||||
// Fallback if a custom CSS rule still overrides Tailwind
|
|
||||||
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{icon && (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
className={`input-field ${className} ${padClass}`}
|
|
||||||
style={mergedStyle}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { ReactNode, LabelHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Label({ children, className = '', ...props }: LblProps) {
|
|
||||||
return (
|
|
||||||
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
|
|
||||||
const e = new CustomEvent('definedInteraction', {
|
|
||||||
detail: { eventName, metadata },
|
|
||||||
});
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
|
||||||
const path = usePathname();
|
|
||||||
const isActive = path === href;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={`px-4 py-2 rounded-md transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
|
||||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Navigation() {
|
|
||||||
return (
|
|
||||||
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between h-16">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<NavLink href="/">Home</NavLink>
|
|
||||||
<NavLink href="/products">Products</NavLink>
|
|
||||||
<NavLink href="/search">Search</NavLink>
|
|
||||||
<NavLink href="/cart">Cart</NavLink>
|
|
||||||
<NavLink href="/checkout">Checkout</NavLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
|
|
||||||
interface PriceDisplayProps {
|
|
||||||
productId: string;
|
|
||||||
className?: string;
|
|
||||||
perNight?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricingData {
|
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
cachedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
sessionId: string;
|
|
||||||
experimentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSession = async (): Promise<SessionData> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/session');
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
|
||||||
sessionId: data.sessionId || '',
|
|
||||||
experimentId: data.experimentId || '',
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to fetch session:', err);
|
|
||||||
return { sessionId: '', experimentId: '' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) => {
|
|
||||||
return new Intl.NumberFormat('en-US', { // like an std localization
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
}).format(price);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
|
|
||||||
const cacheTime = new Date(cachedAt).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
return now - cacheTime > thresholdMs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PriceDisplay({
|
|
||||||
productId,
|
|
||||||
className = '',
|
|
||||||
perNight = false,
|
|
||||||
}: PriceDisplayProps) {
|
|
||||||
const sessionRef = useRef<SessionData | null>(null);
|
|
||||||
const [data, setData] = useState<PricingData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initAndFetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// fetch session if not already loaded
|
|
||||||
if (!sessionRef.current) {
|
|
||||||
sessionRef.current = await fetchSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sessionId, experimentId } = sessionRef.current;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
setError('Invalid session');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
productId,
|
|
||||||
sessionId,
|
|
||||||
experimentId: experimentId || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(`/api/pricing?${params.toString()}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to fetch price: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricingData: PricingData = await res.json();
|
|
||||||
setData(pricingData);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initAndFetch();
|
|
||||||
}, [productId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className={`price-loading ${className}`}>
|
|
||||||
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
|
|
||||||
<span className="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className={`price-error ${className}`}>
|
|
||||||
<span className="text-red-500 text-sm">Price unavailable</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStale = isCacheStale(data.cachedAt);
|
|
||||||
const formattedPrice = formatPrice(data.price, data.currency);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`price-display ${className}`}>
|
|
||||||
<div className="price-amount">
|
|
||||||
{formattedPrice}
|
|
||||||
{perNight && <span className="text-xs ml-1">/night</span>}
|
|
||||||
</div>
|
|
||||||
{isStale && (
|
|
||||||
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
|
|
||||||
prices may be outdated
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
interface RadioOpt<T extends string> {
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadioGrpProps<T extends string> {
|
|
||||||
name: string;
|
|
||||||
options: RadioOpt<T>[];
|
|
||||||
value: T;
|
|
||||||
onChange: (val: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<label key={opt.value} className="flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={name}
|
|
||||||
value={opt.value}
|
|
||||||
checked={value === opt.value}
|
|
||||||
onChange={(e) => onChange(e.target.value as T)}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{opt.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { default as Button } from './Button';
|
|
||||||
export { default as Label } from './Label';
|
|
||||||
export { default as Input } from './Input';
|
|
||||||
export { default as DateInput } from './DateInput';
|
|
||||||
export { default as RadioGroup } from './RadioGroup';
|
|
||||||
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
|
||||||
export { default as Navigation } from './Navigation';
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
'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,63 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseHoverTrackingOptions {
|
|
||||||
eventName: EventName;
|
|
||||||
productId?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
|
|
||||||
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
|
|
||||||
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
|
|
||||||
: 1500;
|
|
||||||
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
||||||
const startRef = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
return useCallback((node: HTMLElement | null) => {
|
|
||||||
if (!node) {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEnter = () => {
|
|
||||||
startRef.current = Date.now();
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
const dwellTime = Date.now() - startRef.current!;
|
|
||||||
dispatchInteraction(eventName, productId, {
|
|
||||||
...metadata,
|
|
||||||
dwellTime,
|
|
||||||
});
|
|
||||||
}, threshold);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onLeave = () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
node.addEventListener('mouseenter', onEnter);
|
|
||||||
node.addEventListener('mouseleave', onLeave);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
node.removeEventListener('mouseenter', onEnter);
|
|
||||||
node.removeEventListener('mouseleave', onLeave);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, [eventName, productId, metadata, threshold]);
|
|
||||||
};
|
|
||||||
@@ -1,90 +1,117 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import '@/lib/experiments'
|
import '@/lib/experiments' // ensure experiments lib is loaded
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
|
|
||||||
const fetchSessionId = async (): Promise<string> => {
|
const genSessionId = () => {
|
||||||
try {
|
if (typeof window === 'undefined') return '';
|
||||||
const res = await fetch('/api/session');
|
let sid = sessionStorage.getItem('phantom_session_id');
|
||||||
const data = await res.json();
|
if (!sid) {
|
||||||
return data.sessionId || '';
|
sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
} catch (err) {
|
sessionStorage.setItem('phantom_session_id', sid);
|
||||||
console.error('failed to fetch session:', err);
|
// TODO: when creating new id send to exepriemtn tracking db
|
||||||
return '';
|
// match between sesion-id and experiment-id for this session
|
||||||
}
|
// so that we can identify all interactions aligning with a specific experiment goal.
|
||||||
|
}
|
||||||
|
return sid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const track = async (ev: {
|
const track = async (ev: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
eventName: EventName;
|
eventType: string;
|
||||||
page: string;
|
targetEl?: string;
|
||||||
productId?: string;
|
targetUrl?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, any>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const experimentId = localStorage.getItem('phantom_experiment_id');
|
await fetch('/api/track', {
|
||||||
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,
|
} catch (err) {
|
||||||
experimentId: experimentId || undefined,
|
console.error('track failed:', err);
|
||||||
}),
|
}
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('track failed:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInteractionTracking = () => {
|
export const useInteractionTracking = () => {
|
||||||
const sidRef = useRef<string>('');
|
const sidRef = useRef<string>('');
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// fetch session id from httpOnly cookie via API
|
sidRef.current = genSessionId();
|
||||||
fetchSessionId().then((sid) => {
|
|
||||||
sidRef.current = sid;
|
|
||||||
setReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePageView = () => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (!sidRef.current) return;
|
const tgt = e.target as HTMLElement;
|
||||||
const page = window.location.pathname;
|
|
||||||
track({
|
track({
|
||||||
sessionId: sidRef.current,
|
sessionId: sidRef.current,
|
||||||
eventName: 'page_view',
|
eventType: 'click',
|
||||||
page,
|
targetEl: tgt.tagName,
|
||||||
|
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
path: window.location.pathname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
track({
|
||||||
|
sessionId: sidRef.current,
|
||||||
|
eventType: 'scroll',
|
||||||
|
metadata: {
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
path: window.location.pathname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageView = () => {
|
||||||
|
track({
|
||||||
|
sessionId: sidRef.current,
|
||||||
|
eventType: 'pageview',
|
||||||
|
metadata: {
|
||||||
|
path: window.location.pathname,
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// called for canonical events dispatched via custom events
|
enum DefinedInteractions {
|
||||||
const handleDefinedInteraction = (e: Event) => {
|
ADD_TO_CART = 'add_to_cart',
|
||||||
if (!sidRef.current) return;
|
PURCHASE = 'purchase',
|
||||||
const customEvent = e as CustomEvent<{
|
}
|
||||||
eventName: EventName;
|
|
||||||
productId?: string;
|
// called when clicking on "Add to Cart" button or "Purchase" button
|
||||||
metadata?: Record<string, unknown>;
|
const handleDefinedInteraction = (
|
||||||
}>;
|
interactionType: DefinedInteractions,
|
||||||
const page = window.location.pathname;
|
metadata?: Record<string, any>
|
||||||
|
) => {
|
||||||
track({
|
track({
|
||||||
sessionId: sidRef.current,
|
sessionId: sidRef.current,
|
||||||
eventName: customEvent.detail.eventName,
|
eventType: interactionType,
|
||||||
page,
|
metadata: {
|
||||||
productId: customEvent.detail.productId,
|
path: window.location.pathname,
|
||||||
metadata: customEvent.detail.metadata,
|
...metadata,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// wait for session to be ready before tracking
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
handlePageView();
|
handlePageView();
|
||||||
document.addEventListener('definedInteraction', handleDefinedInteraction);
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('definedInteraction', (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent;
|
||||||
|
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
|
||||||
|
});
|
||||||
|
// TOO NOISY: enable if needed but tbh not worth it
|
||||||
|
//window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('definedInteraction', handleDefinedInteraction);
|
document.removeEventListener('click', handleClick);
|
||||||
|
document.removeEventListener('definedInteraction', (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent;
|
||||||
|
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
|
||||||
|
});
|
||||||
|
//window.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
}, [ready]);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type SessionState = {
|
|
||||||
sessionId: string | null;
|
|
||||||
experimentId: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSession = () => {
|
|
||||||
const [state, setState] = useState<SessionState>({
|
|
||||||
sessionId: null,
|
|
||||||
experimentId: null,
|
|
||||||
isLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSession = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/session');
|
|
||||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
setState({
|
|
||||||
sessionId: data.sessionId || null,
|
|
||||||
experimentId: data.experimentId || null,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('session fetch error:', err);
|
|
||||||
setState({ sessionId: null, experimentId: null, isLoading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSession();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
type Env = z.infer<typeof envSchema>;
|
|
||||||
const envSchema = z.object({
|
|
||||||
STORE_MODE: z.enum(['hotel', 'airline'], {
|
|
||||||
message: 'STORE_MODE must be either "hotel" or "airline"'
|
|
||||||
}),
|
|
||||||
NEXT_PUBLIC_API_BASE: z.string().url({
|
|
||||||
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
|
|
||||||
}),
|
|
||||||
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
|
|
||||||
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// parse and validate env at module load, fail fast with descriptive errors
|
|
||||||
const parseEnv = (): Env => {
|
|
||||||
const result = envSchema.safeParse({
|
|
||||||
STORE_MODE: process.env.STORE_MODE,
|
|
||||||
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
|
||||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
|
||||||
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
|
||||||
throw new Error(`Environment validation failed:\n${errors}`);
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config: Env = parseEnv();
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// canonical events for tracking user interactions
|
|
||||||
export type EventName =
|
|
||||||
// navigation & discovery
|
|
||||||
| 'page_view'
|
|
||||||
| 'view_item_page'
|
|
||||||
| 'learn_more_about_item'
|
|
||||||
// cart operations
|
|
||||||
| 'add_item_to_cart'
|
|
||||||
| 'remove_item'
|
|
||||||
| 'checkout_start'
|
|
||||||
| 'purchase_complete'
|
|
||||||
// filtering & search
|
|
||||||
| 'search'
|
|
||||||
| 'filter_for_date'
|
|
||||||
| 'filter_for_amenities'
|
|
||||||
| 'filter_for_price'
|
|
||||||
| 'sort_change'
|
|
||||||
// dwell signals (Ns threshold)
|
|
||||||
| 'hover_over_title'
|
|
||||||
| 'hover_over_paragraph'
|
|
||||||
| 'hover_over_link'
|
|
||||||
| 'hover_over_button'
|
|
||||||
// session
|
|
||||||
| 'session_start';
|
|
||||||
|
|
||||||
export const eventNames: readonly EventName[] = [
|
|
||||||
'page_view',
|
|
||||||
'view_item_page',
|
|
||||||
'learn_more_about_item',
|
|
||||||
'add_item_to_cart',
|
|
||||||
'remove_item',
|
|
||||||
'checkout_start',
|
|
||||||
'purchase_complete',
|
|
||||||
'search',
|
|
||||||
'filter_for_date',
|
|
||||||
'filter_for_amenities',
|
|
||||||
'filter_for_price',
|
|
||||||
'sort_change',
|
|
||||||
'hover_over_title',
|
|
||||||
'hover_over_paragraph',
|
|
||||||
'hover_over_link',
|
|
||||||
'hover_over_button',
|
|
||||||
'session_start',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface EventBase {
|
|
||||||
sessionId: string;
|
|
||||||
experimentId?: string;
|
|
||||||
storeMode: 'hotel' | 'airline';
|
|
||||||
ts: string; // ISO8601
|
|
||||||
page: string;
|
|
||||||
eventName: EventName;
|
|
||||||
productId?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
userAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// zod schema for runtime validation
|
|
||||||
export const eventBaseSchema = z.object({
|
|
||||||
sessionId: z.string().min(1),
|
|
||||||
experimentId: z.string().optional(),
|
|
||||||
storeMode: z.enum(['hotel', 'airline']),
|
|
||||||
ts: z.string().datetime(), // validates ISO8601
|
|
||||||
page: z.string().min(1),
|
|
||||||
eventName: z.enum([
|
|
||||||
'page_view',
|
|
||||||
'view_item_page',
|
|
||||||
'learn_more_about_item',
|
|
||||||
'add_item_to_cart',
|
|
||||||
'remove_item',
|
|
||||||
'checkout_start',
|
|
||||||
'purchase_complete',
|
|
||||||
'search',
|
|
||||||
'filter_for_date',
|
|
||||||
'filter_for_amenities',
|
|
||||||
'filter_for_price',
|
|
||||||
'sort_change',
|
|
||||||
'hover_over_title',
|
|
||||||
'hover_over_paragraph',
|
|
||||||
'hover_over_link',
|
|
||||||
'hover_over_button',
|
|
||||||
'session_start',
|
|
||||||
]),
|
|
||||||
productId: z.string().optional(),
|
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
userAgent: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type EventBaseValidated = z.infer<typeof eventBaseSchema>;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
42
web/src/lib/kafka.ts
Normal file
42
web/src/lib/kafka.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Kafka, Producer } from 'kafkajs';
|
||||||
|
|
||||||
|
let producer: Producer | null = null;
|
||||||
|
|
||||||
|
const kafka = new Kafka({
|
||||||
|
clientId: 'phantom-web',
|
||||||
|
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getProducer = async (): Promise<Producer> => {
|
||||||
|
if (!producer) {
|
||||||
|
producer = kafka.producer();
|
||||||
|
await producer.connect();
|
||||||
|
}
|
||||||
|
return producer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendInteractionEvent = async (ev: {
|
||||||
|
sessionId: string;
|
||||||
|
eventType: string;
|
||||||
|
targetEl?: string;
|
||||||
|
targetUrl?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
ts: number;
|
||||||
|
}) => {
|
||||||
|
const p = await getProducer();
|
||||||
|
// add to the metadata
|
||||||
|
await p.send({
|
||||||
|
topic: 'user-interactions',
|
||||||
|
messages: [{
|
||||||
|
key: ev.sessionId,
|
||||||
|
value: JSON.stringify(ev),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disconnect = async () => {
|
||||||
|
if (producer) {
|
||||||
|
await producer.disconnect();
|
||||||
|
producer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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';
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
type SessionData = {
|
|
||||||
experimentId?: string;
|
|
||||||
startedAt: number;
|
|
||||||
status: 'active' | 'stopped';
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExperimentData = {
|
|
||||||
id: string;
|
|
||||||
status: 'active' | 'stopped';
|
|
||||||
sessionIds: string[];
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = new Map<string, SessionData>();
|
|
||||||
const experiments = new Map<string, ExperimentData>();
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
key: process.env.AIRTABLE_API_KEY,
|
|
||||||
base: process.env.AIRTABLE_BASE_ID,
|
|
||||||
table: process.env.AIRTABLE_TABLE_NAME || 'Sessions',
|
|
||||||
};
|
|
||||||
|
|
||||||
// sync session to airtable if credentials present
|
|
||||||
const syncToAirtable = async (sid: string, data: SessionData) => {
|
|
||||||
if (!cfg.key || !cfg.base) return; // skip if not configured
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`;
|
|
||||||
await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${cfg.key}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fields: {
|
|
||||||
sessionId: sid,
|
|
||||||
experimentId: data.experimentId || '',
|
|
||||||
startedAt: new Date(data.startedAt).toISOString(),
|
|
||||||
status: data.status,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('airtable sync failed:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSession = (sid: string) => store.get(sid);
|
|
||||||
|
|
||||||
export const createSession = (sid: string) => {
|
|
||||||
const data: SessionData = { startedAt: Date.now(), status: 'active' };
|
|
||||||
store.set(sid, data);
|
|
||||||
syncToAirtable(sid, data); // async fire-and-forget
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setExperiment = (sid: string, expId: string) => {
|
|
||||||
const data = store.get(sid) || createSession(sid);
|
|
||||||
data.experimentId = expId;
|
|
||||||
store.set(sid, data);
|
|
||||||
syncToAirtable(sid, data);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopExperiment = (sid: string) => {
|
|
||||||
const data = store.get(sid);
|
|
||||||
if (data) {
|
|
||||||
data.status = 'stopped';
|
|
||||||
store.set(sid, data);
|
|
||||||
syncToAirtable(sid, data);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// experiment-level operations
|
|
||||||
export const createExperiment = (sid: string, expId: string) => {
|
|
||||||
const exp: ExperimentData = {
|
|
||||||
id: expId,
|
|
||||||
status: 'active',
|
|
||||||
sessionIds: [sid],
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
experiments.set(expId, exp);
|
|
||||||
setExperiment(sid, expId); // link session to experiment
|
|
||||||
console.log(`experiment ${expId} started with session ${sid}`);
|
|
||||||
return exp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopExperimentById = (expId: string) => {
|
|
||||||
const exp = experiments.get(expId);
|
|
||||||
if (exp) {
|
|
||||||
exp.status = 'stopped';
|
|
||||||
experiments.set(expId, exp);
|
|
||||||
console.log(`experiment ${expId} stopped`);
|
|
||||||
}
|
|
||||||
return exp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getExperiment = (expId: string) => experiments.get(expId);
|
|
||||||
|
|
||||||
export const getAllExperiments = () => Array.from(experiments.values());
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export function proxy(req: NextRequest) {
|
|
||||||
const mode = process.env.STORE_MODE;
|
|
||||||
const { pathname } = req.nextUrl;
|
|
||||||
|
|
||||||
// skip rewrites for api routes, admin routes, static files, and next internals
|
|
||||||
if (
|
|
||||||
pathname.startsWith('/api') ||
|
|
||||||
pathname.startsWith('/admin') ||
|
|
||||||
pathname.startsWith('/_next') ||
|
|
||||||
pathname.startsWith('/static') ||
|
|
||||||
pathname.startsWith('/start-task') ||
|
|
||||||
pathname.startsWith('/cart') ||
|
|
||||||
pathname.includes('.')
|
|
||||||
// TODO: add robots.txt and sitemap.xml if needed here
|
|
||||||
) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// already prefixed with mode
|
|
||||||
if (pathname.startsWith(`/${mode}`)) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// rewrite root and unprefixed paths to mode-specific route group
|
|
||||||
const url = req.nextUrl.clone();
|
|
||||||
url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`;
|
|
||||||
|
|
||||||
return NextResponse.rewrite(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
// match all paths except those starting with _next/static, _next/image, favicon.ico
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,38 +1,25 @@
|
|||||||
/* Airline Platform - Sky Blue Theme */
|
/* Airline Platform - Sky Blue Theme */
|
||||||
|
|
||||||
@layer base {
|
:root[data-mode="airline"] {
|
||||||
[data-mode="airline"] {
|
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
--accent-warning: #ff3b30;
|
--accent-warning: #ff3b30;
|
||||||
--accent-primary-hover: #0051d5;
|
--accent-primary-hover: #0051d5;
|
||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
[data-mode="airline"] {
|
[data-mode="airline"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-primary {
|
[data-mode="airline"] .btn-primary {
|
||||||
background-color: var(--accent-primary) !important;
|
background-color: var(--accent-primary);
|
||||||
color: #ffffff !important;
|
color: #ffffff;
|
||||||
padding: 12px 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-primary:hover {
|
[data-mode="airline"] .btn-primary:hover {
|
||||||
background-color: var(--accent-primary-hover);
|
background-color: var(--accent-primary-hover);
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-secondary {
|
[data-mode="airline"] .btn-secondary {
|
||||||
@@ -277,7 +264,6 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .input-field:focus {
|
[data-mode="airline"] .input-field:focus {
|
||||||
@@ -314,8 +300,3 @@
|
|||||||
[data-mode="airline"] .checkbox-label:hover {
|
[data-mode="airline"] .checkbox-label:hover {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .hero-section {
|
|
||||||
background: var(--hero-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/* Hotel Platform - Action Blue Theme */
|
/* Hotel Platform - Action Blue Theme */
|
||||||
|
|
||||||
@layer base {
|
:root[data-mode="hotel"] {
|
||||||
[data-mode="hotel"] {
|
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
--accent-warning: #d9534f;
|
--accent-warning: #d9534f;
|
||||||
@@ -9,11 +8,8 @@
|
|||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
--bg-tertiary: #f5f5f7;
|
--bg-tertiary: #f5f5f7;
|
||||||
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
[data-mode="hotel"] {
|
[data-mode="hotel"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
@@ -21,19 +17,10 @@
|
|||||||
[data-mode="hotel"] .btn-primary {
|
[data-mode="hotel"] .btn-primary {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 12px 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="hotel"] .btn-primary:hover {
|
[data-mode="hotel"] .btn-primary:hover {
|
||||||
background-color: var(--accent-primary-hover);
|
background-color: var(--accent-primary-hover);
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="hotel"] .btn-secondary {
|
[data-mode="hotel"] .btn-secondary {
|
||||||
@@ -411,8 +398,3 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
border-bottom-color: var(--accent-primary);
|
border-bottom-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="hotel"] .hero-section {
|
|
||||||
background: var(--hero-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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!,
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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