mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
23 Commits
first-pric
...
2-nextjs-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3b286cea | |||
| eb360c4ab3 | |||
|
|
d0e9fae5c2 | ||
| 6a2c41bbdb | |||
| ba8c42e30e | |||
| 4f9e3a7ed0 | |||
| 18d935270f | |||
| fd17ea0620 | |||
| 69c1725c9c | |||
| b5361b3f82 | |||
|
|
b10cceae45 | ||
| 0a1e5fca03 | |||
| f1b82fb5f2 | |||
| d3d5f39ec5 | |||
| 0cec8487ba | |||
| 425dd2d9ef | |||
| 8560d09097 | |||
| fee6d6d15a | |||
| 2f8ef07a9c | |||
| ea6d257d46 | |||
| 4d93d45d51 | |||
| d26e8bcaaa | |||
| 8079c87b17 |
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
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,8 +1,6 @@
|
|||||||
**/.env
|
**/.env
|
||||||
**/.venv
|
**/.venv
|
||||||
**/__pycache__
|
PHANTOM.wiki/
|
||||||
**/.ipynb_checkpoints/
|
|
||||||
**/.virtual_documents/
|
**/.virtual_documents/
|
||||||
**/session_*.svg
|
**/__pycache__/
|
||||||
**/*graph.svg
|
**/.ipynb_checkpoints/
|
||||||
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
|
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import uvicorn
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
from kafka import KafkaProducer
|
||||||
from kafka.admin import NewTopic
|
|
||||||
from kafka.errors import TopicAlreadyExistsError
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from supabase import create_client, Client
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -19,43 +16,21 @@ app = FastAPI()
|
|||||||
# kafka producer - lazy init
|
# kafka producer - lazy init
|
||||||
_producer: Optional[KafkaProducer] = None
|
_producer: Optional[KafkaProducer] = None
|
||||||
|
|
||||||
# supabase client - lazy init
|
|
||||||
_supabase: Optional[Client] = None
|
|
||||||
|
|
||||||
def get_supabase() -> Client:
|
|
||||||
global _supabase
|
|
||||||
if _supabase is None:
|
|
||||||
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
|
||||||
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
|
||||||
if not url or not key:
|
|
||||||
raise ValueError("Supabase credentials not configured")
|
|
||||||
_supabase = create_client(url, key)
|
|
||||||
return _supabase
|
|
||||||
|
|
||||||
def get_producer() -> KafkaProducer:
|
def get_producer() -> KafkaProducer:
|
||||||
global _producer
|
global _producer
|
||||||
if _producer is None:
|
if _producer is None:
|
||||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
broker = f'{host}:{port}' if port else host
|
broker = f'{host}:{port}' if port else host
|
||||||
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
|
||||||
_producer = KafkaProducer(
|
_producer = KafkaProducer(
|
||||||
bootstrap_servers=[broker],
|
bootstrap_servers=[broker],
|
||||||
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
||||||
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
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
|
return _producer
|
||||||
|
|
||||||
class EventPayload(BaseModel):
|
class EventPayload(BaseModel):
|
||||||
sessionId: str
|
sessionId: str
|
||||||
experimentId: Optional[str] = None
|
|
||||||
eventName: str
|
eventName: str
|
||||||
page: str
|
page: str
|
||||||
productId: Optional[str] = None
|
productId: Optional[str] = None
|
||||||
@@ -64,14 +39,6 @@ class EventPayload(BaseModel):
|
|||||||
userAgent: Optional[str] = None
|
userAgent: Optional[str] = None
|
||||||
ts: Optional[str] = None
|
ts: Optional[str] = None
|
||||||
|
|
||||||
class PriceLogPayload(BaseModel):
|
|
||||||
productId: str
|
|
||||||
price: float
|
|
||||||
sessionId: str
|
|
||||||
experimentId: Optional[str] = None
|
|
||||||
storeMode: str
|
|
||||||
ts: Optional[str] = None
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -80,34 +47,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
kafka_status = "unknown"
|
kafka_status = "unknown"
|
||||||
@@ -133,13 +72,12 @@ async def ingest_logs(event: EventPayload):
|
|||||||
event.ts = datetime.utcnow().isoformat() + 'Z'
|
event.ts = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
producer = get_producer()
|
producer = get_producer()
|
||||||
future = producer.send(
|
producer.send(
|
||||||
'user-interactions',
|
'user-interactions',
|
||||||
key=event.sessionId,
|
key=event.sessionId,
|
||||||
value=event.model_dump()
|
value=event.model_dump()
|
||||||
)
|
)
|
||||||
# add callback for error logging but don't block
|
producer.flush(timeout=5)
|
||||||
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -148,212 +86,11 @@ async def ingest_logs(event: EventPayload):
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.post("/api/kafka/price-log")
|
|
||||||
async def ingest_price_log(price_log: PriceLogPayload):
|
|
||||||
try:
|
|
||||||
if not price_log.ts:
|
|
||||||
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
producer = get_producer()
|
|
||||||
future = producer.send(
|
|
||||||
'price-logs',
|
|
||||||
key=price_log.productId,
|
|
||||||
value=price_log.model_dump()
|
|
||||||
)
|
|
||||||
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRICE_LOG_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/kafka/dump")
|
@app.get("/api/kafka/dump")
|
||||||
def dump_logs(
|
def dump_logs():
|
||||||
topic: str = 'user-interactions',
|
# TODO: implement a dump of logs of time period t_start to t_end (params of get)
|
||||||
last_n: Optional[int] = None,
|
# OR: allow for params of last_n logs as a param - creating two modes of the dumping
|
||||||
t_start: Optional[str] = None,
|
pass
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ uvicorn[standard]==0.24.0
|
|||||||
kafka-python==2.0.2
|
kafka-python==2.0.2
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
supabase==2.9.1
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- KAFKA_PORT=29092
|
||||||
- BACKEND_PORT=5000
|
|
||||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
|
||||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- kafka
|
- kafka
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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
|
|
||||||
1172
experiments/data_export.ipynb
Normal file
1172
experiments/data_export.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
140
web/package-lock.json
generated
140
web/package-lock.json
generated
@@ -8,8 +8,6 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -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",
|
||||||
@@ -1721,29 +1605,9 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { TaskManager } from '@/components/admin/TaskManager';
|
import { useSession } from '@/hooks/useSession';
|
||||||
import { ExperimentForm } from '@/components/admin/ExperimentForm';
|
|
||||||
|
|
||||||
type Experiment = {
|
type Experiment = {
|
||||||
id: string;
|
id: string;
|
||||||
subject_name: string;
|
status: 'active' | 'stopped';
|
||||||
xp_human_only: boolean;
|
sessionIds: string[];
|
||||||
xp_market_mode: string;
|
createdAt: number;
|
||||||
created_at: string;
|
|
||||||
task?: {
|
|
||||||
id: string;
|
|
||||||
task_name: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExperimentsAdmin() {
|
export default function ExperimentsAdmin() {
|
||||||
|
const { sessionId, isLoading: sessionLoading } = useSession();
|
||||||
const [exps, setExps] = useState<Experiment[]>([]);
|
const [exps, setExps] = useState<Experiment[]>([]);
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
|
|
||||||
const fetchExps = async () => {
|
const fetchExps = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,23 +31,87 @@ export default function ExperimentsAdmin() {
|
|||||||
fetchExps();
|
fetchExps();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleExperimentCreated = async () => {
|
const handleStart = async () => {
|
||||||
setShowForm(false);
|
if (!sessionId) {
|
||||||
setSelectedTaskId(undefined);
|
setError('no session available');
|
||||||
await fetchExps();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/experiments/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'start failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchExps(); // refresh list
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStop = async (expId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/experiments/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ experimentId: expId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'stop failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchExps(); // refresh list
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400">loading session...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<div className="mb-8">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||||
Experiment Management
|
Experiments
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
configure tasks and run experiments
|
current session: {sessionId || 'none'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={loading || !sessionId}
|
||||||
|
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
{loading ? 'starting...' : 'start experiment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||||
@@ -61,57 +119,24 @@ export default function ExperimentsAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
||||||
{/* left column: task manager */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<TaskManager
|
|
||||||
onTaskSelect={setSelectedTaskId}
|
|
||||||
selectedTaskId={selectedTaskId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* right column: experiment form + list */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
Experiments
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
|
||||||
>
|
|
||||||
{showForm ? 'hide form' : 'new experiment'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<ExperimentForm
|
|
||||||
selectedTaskId={selectedTaskId}
|
|
||||||
onSuccess={handleExperimentCreated}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
subject
|
experiment id
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
mode
|
status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
human
|
session count
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
task
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
created
|
created
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
link
|
action
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -119,67 +144,56 @@ export default function ExperimentsAdmin() {
|
|||||||
{exps.length === 0 ? (
|
{exps.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={5}
|
||||||
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
no experiments yet
|
no experiments yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
exps.map((exp) => {
|
exps.map((exp) => (
|
||||||
const baseUrl = exp.xp_market_mode === 'airline'
|
|
||||||
? 'https://phantom-airline.vercel.app'
|
|
||||||
: 'https://phantom-hotel.vercel.app';
|
|
||||||
const link = `${baseUrl}/start-task?uuid=${exp.id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
<tr
|
||||||
key={exp.id}
|
key={exp.id}
|
||||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
|
||||||
{exp.subject_name}
|
{exp.id.slice(0, 8)}...
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-6 py-4">
|
||||||
<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">
|
<span
|
||||||
{exp.xp_market_mode || 'none'}
|
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
exp.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
|
||||||
|
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{exp.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||||
{exp.xp_human_only ? (
|
{exp.sessionIds.length}
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">
|
</td>
|
||||||
yes
|
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||||
</span>
|
{new Date(exp.createdAt).toLocaleString()}
|
||||||
) : (
|
</td>
|
||||||
<span className="text-xs text-zinc-500">no</span>
|
<td className="px-6 py-4">
|
||||||
|
{exp.status === 'active' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStop(exp.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
stop
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
|
||||||
{exp.task ? exp.task.task_name : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
|
||||||
{new Date(exp.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(link);
|
|
||||||
}}
|
|
||||||
className="text-xs font-medium text-zinc-900 hover:text-zinc-600 dark:text-zinc-100 dark:hover:text-zinc-400"
|
|
||||||
>
|
|
||||||
copy link
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,69 +1,73 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
function FlightsList() {
|
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||||
const searchParams = useSearchParams();
|
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||||
const [flights, setFlights] = useState<Flight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
interface Flight {
|
||||||
const fetchFlights = async () => {
|
id: string;
|
||||||
try {
|
departure: { time: string; airport: string };
|
||||||
const url = new URL('/api/products', window.location.origin);
|
arrival: { time: string; airport: string };
|
||||||
url.searchParams.set('type', 'airline');
|
duration: string;
|
||||||
|
stops: number;
|
||||||
// forward all relevant search params to the API
|
cabinClass: CabinClass;
|
||||||
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
fareRule: FareRule;
|
||||||
params.forEach(param => {
|
refundable: boolean;
|
||||||
const val = searchParams.get(param);
|
basePrice: number;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genRandomFlights = (): Flight[] => {
|
||||||
|
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
||||||
|
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
||||||
|
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
||||||
|
|
||||||
|
return Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const depHour = Math.floor(Math.random() * 24);
|
||||||
|
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
|
||||||
|
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
|
||||||
|
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
|
||||||
|
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
|
||||||
|
|
||||||
|
const basePrice = Math.floor(
|
||||||
|
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
|
||||||
|
Math.random() * 300
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `flt-${i}`,
|
||||||
|
departure: {
|
||||||
|
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||||
|
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||||
|
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||||
|
},
|
||||||
|
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
|
||||||
|
stops,
|
||||||
|
cabinClass: cabin,
|
||||||
|
fareRule,
|
||||||
|
refundable: Math.random() > 0.7,
|
||||||
|
basePrice,
|
||||||
};
|
};
|
||||||
fetchFlights();
|
});
|
||||||
}, [searchParams]);
|
};
|
||||||
|
|
||||||
|
export default function AirlineProducts() {
|
||||||
|
const flights = genRandomFlights();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Navigation />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{flights.map((f) => (
|
{flights.map((f) => (
|
||||||
<AirlineCard key={f.id} flight={f} />
|
<AirlineCard key={f.id} flight={f} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AirlineProducts() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
|
||||||
<FlightsList />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/utils/supabase/server';
|
import { getAllExperiments } from '@/lib/sessionStore';
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const exps = getAllExperiments();
|
||||||
const supabase = createClient(cookieStore);
|
return NextResponse.json({ experiments: exps });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get('id');
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('experiments')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
task:tasks(*)
|
|
||||||
`)
|
|
||||||
.eq('id', id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return NextResponse.json({ experiment: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('experiments')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
task:tasks(*)
|
|
||||||
`)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
return NextResponse.json({ experiments: data || [] });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('experiments list error:', err);
|
console.error('experiments list error:', err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -43,44 +13,3 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,17 @@ export async function GET(req: NextRequest) {
|
|||||||
const experimentId = searchParams.get('experimentId');
|
const experimentId = searchParams.get('experimentId');
|
||||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||||
|
|
||||||
|
// log in dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[pricing-api]', {
|
||||||
|
productId,
|
||||||
|
sessionId,
|
||||||
|
experimentId,
|
||||||
|
storeMode,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'productId is required' },
|
{ error: 'productId is required' },
|
||||||
@@ -23,46 +34,11 @@ export async function GET(req: NextRequest) {
|
|||||||
// stub: call external pricing provider (random for now)
|
// stub: call external pricing provider (random for now)
|
||||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||||
const price = Math.round(basePrice * 100) / 100;
|
const price = Math.round(basePrice * 100) / 100;
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// log price to kafka for elasticity computation
|
|
||||||
if (sessionId) {
|
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
|
||||||
try {
|
|
||||||
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
productId,
|
|
||||||
price,
|
|
||||||
sessionId,
|
|
||||||
experimentId: experimentId || undefined,
|
|
||||||
storeMode,
|
|
||||||
ts: timestamp,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[price-log-error]', err);
|
|
||||||
// don't fail the pricing request if logging fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// log in dev
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[pricing-api]', {
|
|
||||||
productId,
|
|
||||||
sessionId,
|
|
||||||
experimentId,
|
|
||||||
storeMode,
|
|
||||||
price,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: PricingResponse = {
|
const response: PricingResponse = {
|
||||||
price,
|
price,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
cachedAt: timestamp,
|
cachedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
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,12 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
|
import { getSession, createSession } from '@/lib/sessionStore';
|
||||||
|
|
||||||
const COOKIE_NAME = 'phantom_session_id';
|
const COOKIE_NAME = 'phantom_session_id';
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// check for existing session cookie
|
||||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
@@ -17,11 +18,13 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mint new session id
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
createSession(sessionId);
|
createSession(sessionId);
|
||||||
|
|
||||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
||||||
|
|
||||||
|
// set httpOnly cookie with security flags
|
||||||
res.cookies.set({
|
res.cookies.set({
|
||||||
name: COOKIE_NAME,
|
name: COOKIE_NAME,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
@@ -29,7 +32,7 @@ export async function GET(req: NextRequest) {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: isProd,
|
secure: isProd,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@@ -41,52 +44,3 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const { experimentId } = body;
|
|
||||||
|
|
||||||
if (!experimentId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'experimentId is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessionId = req.cookies.get(COOKIE_NAME)?.value;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
sessionId = randomUUID();
|
|
||||||
createSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setExperiment(sessionId, experimentId);
|
|
||||||
|
|
||||||
const res = NextResponse.json({
|
|
||||||
sessionId,
|
|
||||||
experimentId,
|
|
||||||
success: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!req.cookies.get(COOKIE_NAME)) {
|
|
||||||
res.cookies.set({
|
|
||||||
name: COOKIE_NAME,
|
|
||||||
value: sessionId,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: isProd,
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('session update error:', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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,69 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
function RoomsList() {
|
interface Hotel {
|
||||||
const searchParams = useSearchParams();
|
id: string;
|
||||||
const [rooms, setRooms] = useState<Hotel[]>([]);
|
name: string;
|
||||||
const [loading, setLoading] = useState(true);
|
roomType: string;
|
||||||
const [error, setError] = useState<string | null>(null);
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
useEffect(() => {
|
amenities: string[];
|
||||||
const fetchRooms = async () => {
|
refundable: boolean;
|
||||||
try {
|
pricePerNight: number;
|
||||||
const url = new URL('/api/products', window.location.origin);
|
nights: number;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genRandomHotels = (): Hotel[] => {
|
||||||
|
const names = [
|
||||||
|
'Grand Plaza Hotel',
|
||||||
|
'Seaside Resort',
|
||||||
|
'Downtown Suites',
|
||||||
|
'Mountain View Lodge',
|
||||||
|
'City Center Inn',
|
||||||
|
'Luxury Beach Resort',
|
||||||
|
'Urban Boutique Hotel',
|
||||||
|
'Garden View Hotel',
|
||||||
|
];
|
||||||
|
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
|
||||||
|
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const nights = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const basePrice = Math.floor(80 + Math.random() * 220);
|
||||||
|
const selectedAmenities = amenities
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, Math.floor(Math.random() * 3) + 2);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const checkInDate = new Date(today);
|
||||||
|
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
|
||||||
|
const checkOutDate = new Date(checkInDate);
|
||||||
|
checkOutDate.setDate(checkInDate.getDate() + nights);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `htl-${i}`,
|
||||||
|
name: names[i % names.length],
|
||||||
|
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
|
||||||
|
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
|
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
|
amenities: selectedAmenities,
|
||||||
|
refundable: Math.random() > 0.5,
|
||||||
|
pricePerNight: basePrice,
|
||||||
|
nights,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
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() {
|
export default function HotelProducts() {
|
||||||
|
const hotels = genRandomHotels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||||
<RoomsList />
|
<div className="space-y-4">
|
||||||
</Suspense>
|
{hotels.map((h) => (
|
||||||
|
<HotelCard key={h.id} hotel={h} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</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,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import type { Flight } from '@/lib/airline-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -12,17 +11,32 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
document.dispatchEvent(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||||
|
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||||
|
|
||||||
|
interface Flight {
|
||||||
|
id: string;
|
||||||
|
departure: { time: string; airport: string };
|
||||||
|
arrival: { time: string; airport: string };
|
||||||
|
duration: string;
|
||||||
|
stops: number;
|
||||||
|
cabinClass: CabinClass;
|
||||||
|
fareRule: FareRule;
|
||||||
|
refundable: boolean;
|
||||||
|
basePrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||||
const durationRef = useHoverTracking({
|
const durationRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
metadata: { elementText: flight.duration },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -30,9 +44,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
cabinClass: flight.cabinClass,
|
cabinClass: flight.cabinClass,
|
||||||
fareRule: flight.fareRule,
|
fareRule: flight.fareRule,
|
||||||
price: flight.basePrice,
|
price: flight.basePrice,
|
||||||
dateIndex: flight.dateIndex,
|
|
||||||
});
|
});
|
||||||
window.location.href = `/airline/products/${flight.id}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function AirlineHero() {
|
export default function AirlineHero() {
|
||||||
const router = useRouter();
|
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
@@ -31,23 +28,7 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const params = new URLSearchParams();
|
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
||||||
|
|
||||||
if (departDate) {
|
|
||||||
const daysOffset = dateToDaysFromToday(departDate);
|
|
||||||
params.set('dateIndex', daysOffset.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin) params.set('origin', origin);
|
|
||||||
if (destination) params.set('destination', destination);
|
|
||||||
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
|
||||||
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
|
||||||
|
|
||||||
params.set('adults', passengers.adults.toString());
|
|
||||||
params.set('children', passengers.children.toString());
|
|
||||||
params.set('infants', passengers.infants.toString());
|
|
||||||
|
|
||||||
router.push(`/airline/products?${params.toString()}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -12,6 +11,18 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
document.dispatchEvent(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Hotel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roomType: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
amenities: string[];
|
||||||
|
refundable: boolean;
|
||||||
|
pricePerNight: number;
|
||||||
|
nights: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AmenityIcon = ({ name }: { name: string }) => {
|
const AmenityIcon = ({ name }: { name: string }) => {
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
wifi: 'Wi-Fi',
|
wifi: 'Wi-Fi',
|
||||||
@@ -28,13 +39,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
const titleRef = useHoverTracking({
|
const titleRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
metadata: { elementText: hotel.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -42,9 +53,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
roomType: hotel.roomType,
|
roomType: hotel.roomType,
|
||||||
price: hotel.pricePerNight,
|
price: hotel.pricePerNight,
|
||||||
nights: hotel.nights,
|
nights: hotel.nights,
|
||||||
dateIndex: hotel.dateIndex,
|
|
||||||
});
|
});
|
||||||
window.location.href = `/hotel/products/${hotel.id}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
const LocationIcon = () => (
|
const LocationIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -13,25 +11,14 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function HotelHero() {
|
export default function HotelHero() {
|
||||||
const router = useRouter();
|
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [checkIn, setCheckIn] = useState('');
|
const [checkIn, setCheckIn] = useState('');
|
||||||
|
const [checkOut, setCheckOut] = useState('');
|
||||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const params = new URLSearchParams();
|
console.log({ destination, checkIn, checkOut, guests });
|
||||||
|
|
||||||
if (checkIn) {
|
|
||||||
const daysOffset = dateToDaysFromToday(checkIn);
|
|
||||||
params.set('dateIndex', daysOffset.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (destination) params.set('destination', destination);
|
|
||||||
params.set('adults', guests.adults.toString());
|
|
||||||
params.set('rooms', guests.rooms.toString());
|
|
||||||
|
|
||||||
router.push(`/hotel/products?${params.toString()}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,16 +26,16 @@ export default function HotelHero() {
|
|||||||
<div className="w-full max-w-4xl px-4">
|
<div className="w-full max-w-4xl px-4">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
Find your perfect room
|
Find your perfect stay
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg">
|
<p className="text-lg">
|
||||||
Search rooms, compare prices, and book with confidence
|
Search hotels, compare prices, and book with confidence
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="search-form">
|
<form onSubmit={handleSearch} className="search-form">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="destination">Where to?</Label>
|
<Label htmlFor="destination">Where to?</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -62,7 +49,7 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
<Label htmlFor="checkIn">Check-in</Label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="checkIn"
|
id="checkIn"
|
||||||
value={checkIn}
|
value={checkIn}
|
||||||
@@ -72,27 +59,43 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="guests">Guests</Label>
|
<Label htmlFor="checkOut">Check-out</Label>
|
||||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
<DateInput
|
||||||
|
id="checkOut"
|
||||||
|
value={checkOut}
|
||||||
|
onChange={(e) => setCheckOut(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
|
<Label htmlFor="guests">Guests & Rooms</Label>
|
||||||
|
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
||||||
<DropdownCounter
|
<DropdownCounter
|
||||||
label="Adults"
|
label="Adults"
|
||||||
value={guests.adults}
|
value={guests.adults}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||||
/>
|
/>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Rooms"
|
||||||
|
value={guests.rooms}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||||
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
<Button type="submit" fullWidth>
|
<Button type="submit" fullWidth>
|
||||||
Search Rooms
|
Search Hotels
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import '@/lib/experiments'
|
import '@/lib/experiments' // ensure experiments lib is loaded
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
|
|
||||||
const fetchSessionId = async (): Promise<string> => {
|
const fetchSessionId = async (): Promise<string> => {
|
||||||
@@ -21,14 +21,10 @@ const track = async (ev: {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const experimentId = localStorage.getItem('phantom_experiment_id');
|
|
||||||
await fetch('/api/ingest', {
|
await fetch('/api/ingest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(ev),
|
||||||
...ev,
|
|
||||||
experimentId: experimentId || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('track failed:', err);
|
console.error('track failed:', err);
|
||||||
|
|||||||
@@ -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,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);
|
|
||||||
};
|
|
||||||
@@ -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';
|
|
||||||
};
|
|
||||||
@@ -10,8 +10,6 @@ export function proxy(req: NextRequest) {
|
|||||||
pathname.startsWith('/admin') ||
|
pathname.startsWith('/admin') ||
|
||||||
pathname.startsWith('/_next') ||
|
pathname.startsWith('/_next') ||
|
||||||
pathname.startsWith('/static') ||
|
pathname.startsWith('/static') ||
|
||||||
pathname.startsWith('/start-task') ||
|
|
||||||
pathname.startsWith('/cart') ||
|
|
||||||
pathname.includes('.')
|
pathname.includes('.')
|
||||||
// TODO: add robots.txt and sitemap.xml if needed here
|
// TODO: add robots.txt and sitemap.xml if needed here
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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