Compare commits

..

22 Commits

Author SHA1 Message Date
b61639db85 fixing chars 2025-11-25 10:46:17 +01:00
7abe603a01 chore: fixing paper build 2025-11-25 10:37:05 +01:00
df8b68441a fix: build of web/ 2025-11-24 23:53:10 +01:00
d66bed4b99 chore: moving route elsewhere and align 2025-11-24 23:51:39 +01:00
e301110184 refactored design 2025-11-24 23:49:31 +01:00
bdc2ea86a2 feat: huge push of product changes and item review with cart 2025-11-24 23:43:05 +01:00
b72d2610ed chore: airline basic refactor 2025-11-24 22:52:59 +01:00
06852aff64 data seeding 2025-11-23 19:04:41 +01:00
62a96d3841 fixing changes of imports 2025-11-23 18:44:10 +01:00
1c6bca9f52 trackion of date index searching 2025-11-22 22:07:20 +01:00
2ae027dba2 refactoring and demand estimation 2025-11-22 22:07:07 +01:00
2661b841fc minor pipeline refactor 2025-11-22 21:09:04 +01:00
3cb1201acc supabase product proxy and rendering 2025-11-19 09:05:48 +01:00
Daniel Alves Rösel
894ce87a5d introduced supabase and experiment management UI (#23)
* introduced supabase and experiment management UI

* fixing cookie import
2025-11-18 20:45:11 +01:00
Daniel Alves Rösel
ab8b8787a8 13 agentic behavior runner v1 (#14)
* baseline setup of agent abstract

* feat: new implementation of simple AI agent that can follow a goal and return

* refactored import structure and created full tests

* pytest setup a github workflow to run tests + more ignores

* singularity for pushing

* fixing builds of PDFs

* inital structure of docs

* init styles and docs

* basic style implementation

* 13 create outline for research paper draft (#18)

* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements

* updated outline for paper from issue

* Refactor docker-compose services to use individual Dockerfiles (#20)

* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* 2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)

* chore: cleaning gitignore

* formating and env documentation

* feat: context switching of hotel/airline depndent on env var via middleware

* fixed alignment and building

* wrong file

* prods

* fixed applying style

* better session cookie management

* tentative session storage with maybe using airtable

* migrated api of ingestion

* events and products apge

* fixing build

* 13 create outline for research paper draft (#18)

* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements

* updated outline for paper from issue

* upadted text formating

* event unification

* refactor tracking to ues callbacks instead of refs

* implement a pricing display api with session passing

* moved middleware to proxy according to new changes in Nextjs

* refactoed kafka ingestion to go via backend not web-db

* Refactor docker-compose services to use individual Dockerfiles (#20)

* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* fixing small bugs and adding exepriments to tracking

* added some doc

* fixing prod

* prod kafka server logging

* topic auto create

* pytest setup a github workflow to run tests + more ignores

* getting data from agents properly

* proper pipeline to handle data and build matrices

* fixing backend dumping

* fixing agents and ignore

* fixing import for tests

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-11-15 16:16:01 +01:00
9bb6f842f4 topic auto create 2025-11-13 18:41:37 +01:00
53a39b07dd prod kafka server logging 2025-11-13 18:27:36 +01:00
4acfb019f8 fixing prod 2025-11-13 18:21:07 +01:00
Daniel Alves Rösel
37b2099ee0 2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)
* chore: cleaning gitignore

* formating and env documentation

* feat: context switching of hotel/airline depndent on env var via middleware

* fixed alignment and building

* wrong file

* prods

* fixed applying style

* better session cookie management

* tentative session storage with maybe using airtable

* migrated api of ingestion

* events and products apge

* fixing build

* 13 create outline for research paper draft (#18)

* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements

* updated outline for paper from issue

* upadted text formating

* event unification

* refactor tracking to ues callbacks instead of refs

* implement a pricing display api with session passing

* moved middleware to proxy according to new changes in Nextjs

* refactoed kafka ingestion to go via backend not web-db

* Refactor docker-compose services to use individual Dockerfiles (#20)

* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* fixing small bugs and adding exepriments to tracking

* added some doc
2025-11-13 18:07:27 +01:00
Copilot
7ece6e82cb Refactor docker-compose services to use individual Dockerfiles (#20)
* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2025-11-12 16:11:50 +01:00
6b7060450c updated outline for paper from issue 2025-11-07 14:46:02 +01:00
Daniel Alves Rösel
f6e780fdf1 13 create outline for research paper draft (#18)
* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements
2025-11-07 14:39:59 +01:00
53 changed files with 2769 additions and 1527 deletions

30
.github/workflows/pytest.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
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
View File

@@ -1,6 +1,8 @@
**/.env
**/.venv
PHANTOM.wiki/
**/__pycache__
**/.ipynb_checkpoints/
**/.virtual_documents/
**/__pycache__/
**/.ipynb_checkpoints/
**/session_*.svg
**/*graph.svg
paper/src/bib/auto

View File

@@ -4,6 +4,10 @@ BUILDDIR := build
TEX := main.tex
JOBNAME := main
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
.DEFAULT_GOAL := help
@@ -35,5 +39,14 @@ clean:
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
rm -rf paper/$(BUILDDIR)/*
$(VENV):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
.PHONY: all pdf clean watch run.webapp
install: $(VENV)
$(PIP) install -r requirements.txt
test: $(VENV)
$(PYTEST) -v
.PHONY: all pdf clean watch run.webapp install test

View File

@@ -7,8 +7,11 @@ import uvicorn
import os
import json
from datetime import datetime
from kafka import KafkaProducer
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
from kafka.admin import NewTopic
from kafka.errors import TopicAlreadyExistsError
from dotenv import load_dotenv
from supabase import create_client, Client
load_dotenv()
app = FastAPI()
@@ -16,21 +19,43 @@ app = FastAPI()
# kafka producer - lazy init
_producer: Optional[KafkaProducer] = None
# supabase client - lazy init
_supabase: Optional[Client] = None
def get_supabase() -> Client:
global _supabase
if _supabase is None:
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
if not url or not key:
raise ValueError("Supabase credentials not configured")
_supabase = create_client(url, key)
return _supabase
def get_producer() -> KafkaProducer:
global _producer
if _producer is None:
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
broker = f'{host}:{port}' if port else host
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
_producer = KafkaProducer(
bootstrap_servers=[broker],
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
key_serializer=lambda k: k.encode('utf-8') if k else None,
acks=1,
retries=3,
max_in_flight_requests_per_connection=5,
request_timeout_ms=30000,
api_version_auto_timeout_ms=10000,
max_block_ms=5000, # don't block send() for more than 5s
)
print(f"[KAFKA_INIT] Producer created successfully")
return _producer
class EventPayload(BaseModel):
sessionId: str
experimentId: Optional[str] = None
eventName: str
page: str
productId: Optional[str] = None
@@ -47,6 +72,33 @@ app.add_middleware(
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)
]
admin.create_topics(new_topics=topics, validate_only=False)
print(f"[STARTUP] Topics created successfully")
admin.close()
except TopicAlreadyExistsError:
print(f"[STARTUP] Topics already exist, skipping creation")
except Exception as e:
print(f"[STARTUP] Failed to create topics: {e}")
print(f"[STARTUP] Will rely on auto-creation on first message")
@app.get("/health")
async def health():
kafka_status = "unknown"
@@ -72,12 +124,13 @@ async def ingest_logs(event: EventPayload):
event.ts = datetime.utcnow().isoformat() + 'Z'
producer = get_producer()
producer.send(
future = producer.send(
'user-interactions',
key=event.sessionId,
value=event.model_dump()
)
producer.flush(timeout=5)
# add callback for error logging but don't block
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
return {"success": True}
except Exception as e:
@@ -87,10 +140,186 @@ async def ingest_logs(event: EventPayload):
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/kafka/dump")
def dump_logs():
# TODO: implement a dump of logs of time period t_start to t_end (params of get)
# OR: allow for params of last_n logs as a param - creating two modes of the dumping
pass
def dump_logs(
last_n: Optional[int] = None,
t_start: Optional[str] = None,
t_end: Optional[str] = None
):
"""dump all messages from user-interactions topic
params:
last_n: return only last n messages (default: all)
t_start: filter by start timestamp iso format (future use)
t_end: filter by end timestamp iso format (future use)
"""
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
broker = f'{host}:{port}'
try:
consumer = KafkaConsumer(
'user-interactions',
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:
# filter by timestamp range if provided
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))

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0
kafka-python==2.0.2
pydantic==2.5.0
python-dotenv==1.0.0
supabase==2.9.1

View File

@@ -9,6 +9,9 @@ services:
environment:
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
- BACKEND_PORT=5000
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
depends_on:
- kafka
restart: unless-stopped

0
experiments/__init__.py Normal file
View File

View File

@@ -0,0 +1 @@
"""Agentic behavior runner for PHANTOM research platform."""

View File

@@ -0,0 +1,44 @@
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= "Name all the products on this site and try to find out more about each product by clicking into them (they might not open)"
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal=JTBD, url="http://localhost:3000/products", timeout=300)
R=asyncio.run(agent.act())
print(R)

View File

@@ -0,0 +1,19 @@
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

View File

@@ -0,0 +1,30 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
from .extract import (
KafkaDataFetcher,
ExperimentJoiner,
EventTitleAugmenter,
)
from .demand import DemandEstimator
from .mapping import SessionTransitionProbMatrixTransformer, render_graph
from .pipeline import etl_pipeline, pricing_pipeline
__all__ = [
'KafkaDataFetcher',
'ExperimentJoiner',
'EventTitleAugmenter',
'DemandEstimator',
'SessionTransitionProbMatrixTransformer',
'render_graph',
'etl_pipeline',
'pricing_pipeline',
]

View File

@@ -0,0 +1,39 @@
from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
import pandas as pd
from supabase import create_client, Client
import pandas as pd
import os
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
class DemandEstimator(BaseEstimator, TransformerMixin):
def __init__(self,
store_mode:str='hotel',
session_filter:str="",
experiment_filter:str=""):
self.store=store_mode
self.session_filter=session_filter if len(session_filter)>0 else None
self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None
def fit(self, X):
return self
def transform(self, interactions : pd.DataFrame):
if interactions.empty:
return pd.DataFrame(columns=["productId", "demand_score"])
if self.session_filter:
interactions = interactions[interactions['sessionId'] == self.session_filter]
if self.experiment_filter:
interactions = interactions[interactions['experimentId'] == self.experiment_filter]
products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute()
products = pd.DataFrame(products.data)
unique_products = products['id'].unique()
# TODO: improve demand score calculation rather than just counting interactions (use weights..)
# while maintaining simplicity of a simple cross tab approach
product_demand = pd.crosstab(interactions['productId'], "no_of_interactions")
product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index()
product_demand.columns = ['productId', 'demand_score']
return product_demand

View File

@@ -0,0 +1,112 @@
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
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 fit(self, X=None, y=None):
return self
def transform(self, X=None):
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump")
resp.raise_for_status()
data = resp.json()
if not data.get('success') or not data.get('data'):
return pd.DataFrame()
df = pd.DataFrame(data['data'])
# explode metadata col json
if 'metadata' in df.columns:
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
df = df.dropna(subset=['eventName'])
# remape dateIndex
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
return df
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

View File

@@ -0,0 +1,158 @@
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)

View File

@@ -0,0 +1,22 @@
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter
from mapping import SessionTransitionProbMatrixTransformer, render_graph
from demand import DemandEstimator
# exposable pipelines
etl_pipeline = Pipeline([
('kafka_fetch', KafkaDataFetcher()),
('experiment_join', ExperimentJoiner()),
('event_augment', EventTitleAugmenter()),
])
pricing_pipeline = Pipeline([
('demand_estimation', DemandEstimator()),
])
if __name__ == "__main__":
processed_data = etl_pipeline.fit_transform(None)
pricing = pricing_pipeline.fit_transform(processed_data)
print(pricing)

View File

@@ -0,0 +1,125 @@
import random
import json
import os
import logging
from dotenv import load_dotenv
from supabase import create_client, Client
from tqdm import tqdm
load_dotenv()
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
log = logging.getLogger(__name__)
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
if not SUPABASE_SERVICE_KEY:
log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment")
raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations")
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
DAYS = 14
# hotel room configurations
ROOMS = {
"Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4},
"Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3},
"Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2},
"Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2},
"Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2},
"Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2},
}
# flight configurations
FLIGHTS = {
"JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250},
"JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850},
"ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180},
"SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420},
"ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600},
"LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120},
"MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380},
"DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160},
"SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720},
"LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850},
}
def gen_hotel_products():
"""generate hotel room products for next DAYS days"""
data = []
for day in range(DAYS):
for room_type, rdata in ROOMS.items():
data.append({
'room_type': room_type,
'date_index': day + 1,
'metadata': rdata,
'availability': random.randint(0, rdata['total'])
})
return data
def gen_airline_products():
"""generate flight products for next DAYS days"""
data = []
for day in range(DAYS):
for flight_type, fdata in FLIGHTS.items():
data.append({
'flight_type': flight_type,
'date_index': day + 1,
'metadata': fdata,
'availability': random.randint(0, fdata['total'])
})
return data
def clear_table(table_name: str):
"""clear all records from a table"""
try:
resp = supabase.table(table_name).select('id').execute()
if resp.data:
ids = [row['id'] for row in resp.data]
chunk_size = 100
for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"):
chunk = ids[i:i+chunk_size]
supabase.table(table_name).delete().in_('id', chunk).execute()
log.info(f"Deleted {len(ids)} records from {table_name}")
else:
log.info(f"{table_name} already empty")
except Exception as e:
log.error(f"Failed to clear {table_name}: {e}")
raise
def seed_table(table_name: str, data: list[dict]):
"""insert records into a table"""
try:
chunk_size = 100
total = len(data)
for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"):
chunk = data[i:i+chunk_size]
supabase.table(table_name).insert(chunk).execute()
log.info(f"Inserted {total} records into {table_name}")
except Exception as e:
log.error(f"Failed to seed {table_name}: {e}")
raise
def main():
log.info("Generating hotel products...")
hotel_products = gen_hotel_products()
log.info(f"Generated {len(hotel_products)} hotel products")
log.info("Generating airline products...")
airline_products = gen_airline_products()
log.info(f"Generated {len(airline_products)} airline products\n")
log.info("Clearing existing products...")
clear_table('hotel_products')
clear_table('airline_products')
log.info("Seeding products...")
seed_table('hotel_products', hotel_products)
seed_table('airline_products', airline_products)
if __name__ == "__main__":
main()

View File

@@ -16,11 +16,15 @@ mkdir -p "$(dirname "$OUTPUT_FILE")"
add_file() {
local filepath="$1"
local relpath="${filepath#$PROJECT_ROOT/}"
local escaped_path="${relpath//_/\\_}"
# Add section header and code listing (no language-specific highlighting)
echo "\\subsection{${relpath}}" >> "$OUTPUT_FILE"
echo "\\begin{lstlisting}[caption={${relpath}}]" >> "$OUTPUT_FILE"
cat "$filepath" >> "$OUTPUT_FILE"
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
# Convert to ASCII: transliterate what's possible, drop the rest
# LC_ALL=C forces ASCII locale for consistent behavior across environments
LC_ALL=C iconv -f UTF-8 -t ASCII//TRANSLIT//IGNORE "$filepath" 2>/dev/null >> "$OUTPUT_FILE" || \
LC_ALL=C tr -cd '\11\12\15\40-\176' < "$filepath" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"

7
pytest.ini Normal file
View File

@@ -0,0 +1,7 @@
[pytest]
testpaths = experiments
python_files = test*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

View File

@@ -5,3 +5,9 @@ jupyter
ipykernel
matplotlib
graphviz
browser-use
pytest
pytest-asyncio
uv
scikit-learn
supabase

140
web/package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.81.1",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -657,6 +659,97 @@
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -941,12 +1034,17 @@
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"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": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -967,6 +1065,15 @@
"@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": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
@@ -993,6 +1100,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -1605,9 +1721,29 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",

View File

@@ -8,6 +8,8 @@
"start": "next start"
},
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.81.1",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",

View File

@@ -1,20 +1,26 @@
'use client';
import { useEffect, useState } from 'react';
import { useSession } from '@/hooks/useSession';
import { TaskManager } from '@/components/admin/TaskManager';
import { ExperimentForm } from '@/components/admin/ExperimentForm';
type Experiment = {
id: string;
status: 'active' | 'stopped';
sessionIds: string[];
createdAt: number;
subject_name: string;
xp_human_only: boolean;
xp_market_mode: string;
created_at: string;
task?: {
id: string;
task_name: string;
};
};
export default function ExperimentsAdmin() {
const { sessionId, isLoading: sessionLoading } = useSession();
const [exps, setExps] = useState<Experiment[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const fetchExps = async () => {
try {
@@ -31,86 +37,22 @@ export default function ExperimentsAdmin() {
fetchExps();
}, []);
const handleStart = async () => {
if (!sessionId) {
setError('no session available');
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 handleExperimentCreated = async () => {
setShowForm(false);
setSelectedTaskId(undefined);
await fetchExps();
};
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 (
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
<div className="mx-auto max-w-5xl">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
Experiments
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
current session: {sessionId || 'none'}
</p>
</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 className="mx-auto max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
Experiment Management
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
configure tasks and run experiments
</p>
</div>
{error && (
@@ -119,79 +61,123 @@ export default function ExperimentsAdmin() {
</div>
)}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
experiment id
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
status
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
session count
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
created
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
action
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{exps.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
>
no experiments yet
</td>
</tr>
) : (
exps.map((exp) => (
<tr
key={exp.id}
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
{exp.id.slice(0, 8)}...
</td>
<td className="px-6 py-4">
<span
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>
</td>
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
{exp.sessionIds.length}
</td>
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
{new Date(exp.createdAt).toLocaleString()}
</td>
<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>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* left column: task manager */}
<div className="lg:col-span-1">
<TaskManager
onTaskSelect={setSelectedTaskId}
selectedTaskId={selectedTaskId}
/>
</div>
{/* right column: experiment form + list */}
<div className="space-y-6 lg:col-span-2">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Experiments
</h2>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
{showForm ? 'hide form' : 'new experiment'}
</button>
</div>
{showForm && (
<ExperimentForm
selectedTaskId={selectedTaskId}
onSuccess={handleExperimentCreated}
/>
)}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
subject
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
mode
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
human
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
task
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
created
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
link
</th>
</tr>
))
)}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{exps.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
>
no experiments yet
</td>
</tr>
) : (
exps.map((exp) => {
const baseUrl = exp.xp_market_mode === 'airline'
? 'https://phantom-airline.vercel.app'
: 'https://phantom-hotel.vercel.app';
const link = `${baseUrl}/start-task?uuid=${exp.id}`;
return (
<tr
key={exp.id}
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{exp.subject_name}
</td>
<td className="px-4 py-3">
<span className="inline-block rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
{exp.xp_market_mode || 'none'}
</span>
</td>
<td className="px-4 py-3">
{exp.xp_human_only ? (
<span className="text-xs text-green-600 dark:text-green-400">
yes
</span>
) : (
<span className="text-xs text-zinc-500">no</span>
)}
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{exp.task ? exp.task.task_name : '—'}
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{new Date(exp.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={() => {
navigator.clipboard.writeText(link);
}}
className="text-xs font-medium text-zinc-900 hover:text-zinc-600 dark:text-zinc-100 dark:hover:text-zinc-400"
>
copy link
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Navigation } from '@/components/ui';
import { useCart } from '@/contexts/CartContext';
import AirlineDetails from '@/components/feats/airline/AirlineDetails';
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
export default function AirlineProductPage() {
const params = useParams();
const router = useRouter();
const { addItem } = useCart();
const [product, setProduct] = useState<Flight | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [added, setAdded] = useState(false);
const productId = params.id as string;
useEffect(() => {
const fetchProduct = async () => {
try {
const res = await fetch(`/api/products/${productId}`);
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
const json = await res.json();
const transformed = transformProduct(json.data as AirlineProduct);
setProduct(transformed);
// fire learn_more_about_item event when product loads
dispatchInteraction('learn_more_about_item', productId, {
type: 'airline',
dateIndex: transformed.dateIndex,
flightType: transformed.flightType,
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load product');
console.error('[FETCH_FLIGHT_ERROR]', e);
} finally {
setLoading(false);
}
};
fetchProduct();
}, [productId]);
const handleAddToCart = () => {
if (!product) return;
addItem({
id: productId,
type: 'airline',
name: product.flightType,
price: product.basePrice,
metadata: {
departure: product.departure,
arrival: product.arrival,
duration: product.duration,
cabinClass: product.cabinClass,
},
dateIndex: product.dateIndex,
});
dispatchInteraction('add_item_to_cart', productId, {
type: 'airline',
price: product.basePrice,
});
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
return (
<>
<Navigation />
<main className="max-w-4xl mx-auto px-4 py-8">
{loading && <div className="text-center py-8">Loading flight details...</div>}
{error && <div className="text-red-500 text-center py-8">{error}</div>}
{!loading && !error && product && (
<>
<button
onClick={() => router.back()}
className="mt-6 text-blue-600 hover:underline"
>
Back to flights
</button>
<AirlineDetails
product={product}
onAddToCart={handleAddToCart}
addedToCart={added}
/>
</>
)}
</main>
</>
);
}

View File

@@ -1,73 +1,69 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Navigation } from '@/components/ui';
import AirlineCard from '@/components/feats/airline/AirlineCard';
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
type FareRule = 'flexible' | 'standard' | 'basic';
function FlightsList() {
const searchParams = useSearchParams();
const [flights, setFlights] = useState<Flight[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
interface Flight {
id: string;
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabinClass: CabinClass;
fareRule: FareRule;
refundable: boolean;
basePrice: number;
}
useEffect(() => {
const fetchFlights = async () => {
try {
const url = new URL('/api/products', window.location.origin);
url.searchParams.set('type', 'airline');
const genRandomFlights = (): Flight[] => {
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
// forward all relevant search params to the API
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
params.forEach(param => {
const val = searchParams.get(param);
if (val) url.searchParams.set(param, val);
});
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,
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);
}
};
});
};
export default function AirlineProducts() {
const flights = genRandomFlights();
fetchFlights();
}, [searchParams]);
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
{loading && <div className="text-center py-8">Loading...</div>}
{error && <div className="text-red-500 text-center py-8">{error}</div>}
{!loading && !error && (
<div className="space-y-4">
{flights.map((f) => (
<AirlineCard key={f.id} flight={f} />
))}
</div>
)}
</>
);
}
export default function AirlineProducts() {
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
<FlightsList />
</Suspense>
</main>
</>
);

View File

@@ -1,10 +1,40 @@
import { NextResponse } from 'next/server';
import { getAllExperiments } from '@/lib/sessionStore';
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/utils/supabase/server';
import { cookies } from 'next/headers';
export async function GET() {
export async function GET(req: NextRequest) {
try {
const exps = getAllExperiments();
return NextResponse.json({ experiments: exps });
const cookieStore = await cookies();
const supabase = createClient(cookieStore);
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (id) {
const { data, error } = await supabase
.from('experiments')
.select(`
*,
task:tasks(*)
`)
.eq('id', id)
.single();
if (error) throw error;
return NextResponse.json({ experiment: data });
}
const { data, error } = await supabase
.from('experiments')
.select(`
*,
task:tasks(*)
`)
.order('created_at', { ascending: false });
if (error) throw error;
return NextResponse.json({ experiments: data || [] });
} catch (err: any) {
console.error('experiments list error:', err);
return NextResponse.json(
@@ -13,3 +43,44 @@ export async function GET() {
);
}
}
export async function POST(req: NextRequest) {
try {
const cookieStore = await cookies();
const supabase = createClient(cookieStore);
const body = await req.json();
const { subject_name, xp_human_only, xp_market_mode, xp_task_id } = body;
if (!subject_name) {
return NextResponse.json(
{ error: 'subject_name is required' },
{ status: 400 }
);
}
const { data, error } = await supabase
.from('experiments')
.insert([{
subject_name,
xp_human_only: xp_human_only ?? false,
xp_market_mode: xp_market_mode || null,
xp_task_id: xp_task_id || null,
}])
.select(`
*,
task:tasks(*)
`)
.single();
if (error) throw error;
return NextResponse.json({ experiment: data });
} catch (err: any) {
console.error('experiment creation error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/utils/supabase/server';
import { cookies } from 'next/headers';
export async function GET() {
try {
const cookieStore = await cookies();
const supabase = createClient(cookieStore);
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return NextResponse.json({ tasks: data || [] });
} catch (err: any) {
console.error('tasks fetch error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const cookieStore = await cookies();
const supabase = createClient(cookieStore);
const body = await req.json();
const { task_name, task_description, task_def_of_done } = body;
if (!task_name) {
return NextResponse.json(
{ error: 'task_name is required' },
{ status: 400 }
);
}
const { data, error } = await supabase
.from('tasks')
.insert([{ task_name, task_description, task_def_of_done }])
.select()
.single();
if (error) throw error;
return NextResponse.json({ task: data });
} catch (err: any) {
console.error('task creation error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!id) {
return NextResponse.json(
{ error: 'product id is required' },
{ status: 400 }
);
}
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
const url = new URL(`${backendUrl}/api/products/${id}`);
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(`Backend returned ${res.status}`);
}
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
console.error('[PRODUCT_DETAIL_ERROR]', error);
return NextResponse.json(
{ error: 'Failed to fetch product details' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const type = searchParams.get('type');
if (!type || !['hotel', 'airline'].includes(type)) {
return NextResponse.json(
{ error: 'type parameter must be "hotel" or "airline"' },
{ status: 400 }
);
}
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
const url = new URL(`${backendUrl}/api/products/type/${type}`);
// forward all query params to backend (excluding 'type')
searchParams.forEach((value, key) => {
if (key !== 'type') {
url.searchParams.set(key, value);
}
});
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(`Backend returned ${res.status}`);
}
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
console.error('[PRODUCTS_PROXY_ERROR]', error);
return NextResponse.json(
{ error: 'Failed to fetch products' },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { getSession, createSession } from '@/lib/sessionStore';
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
const COOKIE_NAME = 'phantom_session_id';
const isProd = process.env.NODE_ENV === 'production';
export async function GET(req: NextRequest) {
try {
// check for existing session cookie
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
if (existingSession) {
@@ -18,13 +17,11 @@ export async function GET(req: NextRequest) {
});
}
// mint new session id
const sessionId = randomUUID();
createSession(sessionId);
const res = NextResponse.json({ sessionId, experimentId: undefined });
// set httpOnly cookie with security flags
res.cookies.set({
name: COOKIE_NAME,
value: sessionId,
@@ -32,7 +29,7 @@ export async function GET(req: NextRequest) {
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
maxAge: 60 * 60 * 24 * 30,
});
return res;
@@ -44,3 +41,52 @@ export async function GET(req: NextRequest) {
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { experimentId } = body;
if (!experimentId) {
return NextResponse.json(
{ error: 'experimentId is required' },
{ status: 400 }
);
}
let sessionId = req.cookies.get(COOKIE_NAME)?.value;
if (!sessionId) {
sessionId = randomUUID();
createSession(sessionId);
}
setExperiment(sessionId, experimentId);
const res = NextResponse.json({
sessionId,
experimentId,
success: true
});
if (!req.cookies.get(COOKIE_NAME)) {
res.cookies.set({
name: COOKIE_NAME,
value: sessionId,
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 30,
});
}
return res;
} catch (err: any) {
console.error('session update error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

110
web/src/app/cart/page.tsx Normal file
View File

@@ -0,0 +1,110 @@
'use client';
import { Navigation } from '@/components/ui';
import { useCart } from '@/contexts/CartContext';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
export default function CartPage() {
const { items, removeItem, clearCart, itemCount } = useCart();
const handleRemove = (id: string, type: string) => {
removeItem(id);
dispatchInteraction('remove_item', id, { type });
};
let itemTypes = Array.from(new Set(items.map(item => item.type)))[0] || 'items';
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<>
<Navigation />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Shopping Cart</h1>
{itemCount > 0 && (
<button
onClick={clearCart}
className="text-sm text-red-600 hover:underline"
>
Clear cart
</button>
)}
</div>
{itemCount === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">Your cart is empty</p>
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
</div>
) : (
<>
<div className="space-y-4 mb-8">
{items.map(item => (
<div
key={item.id}
className="flex justify-between items-start p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
{item.type}
</span>
<h3 className="font-semibold">{item.name}</h3>
</div>
{item.type === 'hotel' && (
<div className="text-sm text-gray-600">
<p>{String(item.metadata.roomType)}</p>
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
</div>
)}
{item.type === 'airline' && (
<div className="text-sm text-gray-600">
<p>{String(item.metadata.cabinClass)} Class</p>
<p>{String((item.metadata.departure as any)?.airport)} {String((item.metadata.arrival as any)?.airport)}</p>
<p>Duration: {String(item.metadata.duration)}</p>
</div>
)}
</div>
<div className="text-right ml-4">
<p className="text-xl font-bold mb-2">${item.price}</p>
<button
onClick={() => handleRemove(item.id, item.type)}
className="text-sm text-red-600 hover:underline"
>
Remove
</button>
</div>
</div>
))}
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center mb-4">
<span className="text-xl font-semibold">Total</span>
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
</div>
<button
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Proceed to Checkout
</button>
</div>
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Navigation } from '@/components/ui';
import { useCart } from '@/contexts/CartContext';
import HotelDetails from '@/components/feats/hotel/HotelDetails';
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
export default function HotelProductPage() {
const params = useParams();
const router = useRouter();
const { addItem } = useCart();
const [product, setProduct] = useState<Hotel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [added, setAdded] = useState(false);
const productId = params.id as string;
useEffect(() => {
const fetchProduct = async () => {
try {
const res = await fetch(`/api/products/${productId}`);
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
const json = await res.json();
const transformed = transformProduct(json.data as HotelProduct);
setProduct(transformed);
// fire learn_more_about_item event when product loads
dispatchInteraction('learn_more_about_item', productId, {
type: 'hotel',
dateIndex: transformed.dateIndex,
roomType: transformed.roomType,
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load product');
console.error('[FETCH_HOTEL_ERROR]', e);
} finally {
setLoading(false);
}
};
fetchProduct();
}, [productId]);
const handleAddToCart = () => {
if (!product) return;
addItem({
id: productId,
type: 'hotel',
name: product.name,
price: product.pricePerNight,
metadata: {
roomType: product.roomType,
nights: product.nights,
checkIn: product.checkIn,
checkOut: product.checkOut,
},
dateIndex: product.dateIndex,
});
dispatchInteraction('add_item_to_cart', productId, {
type: 'hotel',
price: product.pricePerNight,
});
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
return (
<>
<Navigation />
<main className="max-w-4xl mx-auto px-4 py-8">
{loading && <div className="text-center py-8">Loading hotel details...</div>}
{error && <div className="text-red-500 text-center py-8">{error}</div>}
{!loading && !error && product && (
<>
<button
onClick={() => router.back()}
className="mt-6 text-blue-600 hover:underline"
>
Back to rooms
</button>
<HotelDetails
product={product}
onAddToCart={handleAddToCart}
addedToCart={added}
/>
</>
)}
</main>
</>
);
}

View File

@@ -1,74 +1,69 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Navigation } from '@/components/ui';
import HotelCard from '@/components/feats/hotel/HotelCard';
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
function RoomsList() {
const searchParams = useSearchParams();
const [rooms, setRooms] = useState<Hotel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRooms = async () => {
try {
const url = new URL('/api/products', window.location.origin);
url.searchParams.set('type', 'hotel');
// forward all relevant search params to the API
const params = ['dateIndex', 'destination', 'adults', 'rooms'];
params.forEach(param => {
const val = searchParams.get(param);
if (val) url.searchParams.set(param, val);
});
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
const json = await res.json();
const transformed = json.data.map((p: HotelProduct) => transformProduct(p));
setRooms(transformed);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load products');
console.error('[FETCH_ERROR]', e);
} finally {
setLoading(false);
}
};
fetchRooms();
}, [searchParams]);
return (
<>
<h1 className="text-3xl font-bold mb-6">Available Rooms</h1>
{loading && <div className="text-center py-8">Loading...</div>}
{error && <div className="text-red-500 text-center py-8">{error}</div>}
{!loading && !error && (
<div className="space-y-4">
{rooms.map((r) => (
<HotelCard key={r.id} hotel={r} />
))}
</div>
)}
</>
);
}
const genRandomHotels = (): Hotel[] => {
const names = [
'Grand Plaza Hotel',
'Seaside Resort',
'Downtown Suites',
'Mountain View Lodge',
'City Center Inn',
'Luxury Beach Resort',
'Urban Boutique Hotel',
'Garden View Hotel',
];
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
return Array.from({ length: 10 }, (_, i) => {
const nights = Math.floor(Math.random() * 5) + 1;
const basePrice = Math.floor(80 + Math.random() * 220);
const selectedAmenities = amenities
.sort(() => Math.random() - 0.5)
.slice(0, Math.floor(Math.random() * 3) + 2);
const today = new Date();
const checkInDate = new Date(today);
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
const checkOutDate = new Date(checkInDate);
checkOutDate.setDate(checkInDate.getDate() + nights);
return {
id: `htl-${i}`,
name: names[i % names.length],
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
amenities: selectedAmenities,
refundable: Math.random() > 0.5,
pricePerNight: basePrice,
nights,
};
});
};
export default function HotelProducts() {
const hotels = genRandomHotels();
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
<div className="space-y-4">
{hotels.map((h) => (
<HotelCard key={h.id} hotel={h} />
))}
</div>
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
<RoomsList />
</Suspense>
</main>
</>
);

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { TrackingProvider } from "@/components/TrackingProvider";
import { CartProvider } from "@/contexts/CartContext";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -28,7 +29,9 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TrackingProvider>{children}</TrackingProvider>
<CartProvider>
<TrackingProvider>{children}</TrackingProvider>
</CartProvider>
</body>
</html>
);

View File

@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
const StartTaskContent = () => {
const searchParams = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<'loading' | 'error' | 'redirecting'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const uuid = searchParams.get('uuid');
if (!uuid) {
setError('no experiment UUID provided');
setStatus('error');
return;
}
const validateAndStore = async () => {
try {
const res = await fetch(`/api/admin/experiments?id=${uuid}`);
if (!res.ok) throw new Error('experiment not found');
const data = await res.json();
const exp = data.experiment;
if (!exp) throw new Error('invalid experiment UUID');
localStorage.setItem('phantom_experiment_id', uuid);
await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ experimentId: uuid }),
});
setStatus('redirecting');
setTimeout(() => {
router.push("/");
}, 800);
} catch (err: any) {
setError(err.message || 'failed to start task');
setStatus('error');
}
};
validateAndStore();
}, [searchParams, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<div className="text-center">
{status === 'loading' && (
<div>
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-200 border-t-zinc-900 dark:border-zinc-800 dark:border-t-zinc-100 mx-auto" />
<p className="text-zinc-600 dark:text-zinc-400">validating browser...</p>
</div>
)}
{status === 'redirecting' && (
<div>
<div className="mb-4 text-4xl"></div>
<p className="text-zinc-900 dark:text-zinc-100 font-medium">website loaded</p>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">redirecting to page...</p>
</div>
)}
{status === 'error' && (
<div className="rounded-lg bg-red-50 p-6 dark:bg-red-950">
<p className="text-red-900 dark:text-red-100 font-medium">error</p>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
)}
</div>
</div>
);
};
export default function StartTaskPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<p className="text-zinc-600 dark:text-zinc-400">loading...</p>
</div>
}>
<StartTaskContent />
</Suspense>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
type ExperimentFormProps = {
selectedTaskId?: string;
onSuccess?: () => void;
};
export const ExperimentForm = ({ selectedTaskId, onSuccess }: ExperimentFormProps) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
subject_name: '',
xp_human_only: false,
xp_market_mode: 'hotel' as 'hotel' | 'airline',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/experiments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
xp_task_id: selectedTaskId || null,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'creation failed');
}
setForm({ subject_name: '', xp_human_only: false, xp_market_mode: 'hotel' });
onSuccess?.();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Create Experiment
</h2>
{error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
subject name
</label>
<input
type="text"
value={form.subject_name}
onChange={(e) => setForm({ ...form, subject_name: e.target.value })}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
placeholder="e.g., baseline_dynamic_pricing_v1"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
market mode
</label>
<select
value={form.xp_market_mode}
onChange={(e) => setForm({ ...form, xp_market_mode: e.target.value as 'hotel' | 'airline' })}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
>
<option value="hotel">hotel</option>
<option value="airline">airline</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="human-only"
checked={form.xp_human_only}
onChange={(e) => setForm({ ...form, xp_human_only: e.target.checked })}
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 dark:border-zinc-700 dark:bg-zinc-900"
/>
<label htmlFor="human-only" className="text-sm text-zinc-700 dark:text-zinc-300">
human participants only
</label>
</div>
{selectedTaskId && (
<div className="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
task selected: <span className="font-mono text-xs">{selectedTaskId.slice(0, 8)}...</span>
</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
{loading ? 'creating experiment...' : 'create experiment'}
</button>
</form>
);
};

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useEffect } from 'react';
type Task = {
id: string;
task_name: string;
task_description: string;
task_def_of_done: string;
created_at: string;
};
type TaskManagerProps = {
onTaskSelect?: (taskId: string) => void;
selectedTaskId?: string;
};
export const TaskManager = ({ onTaskSelect, selectedTaskId }: TaskManagerProps) => {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
task_name: '',
task_description: '',
task_def_of_done: '',
});
const [error, setError] = useState<string | null>(null);
const fetchTasks = async () => {
try {
const res = await fetch('/api/admin/tasks');
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const data = await res.json();
setTasks(data.tasks || []);
} catch (err: any) {
setError(err.message);
}
};
useEffect(() => {
fetchTasks();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'creation failed');
}
setForm({ task_name: '', task_description: '', task_def_of_done: '' });
setShowForm(false);
await fetchTasks();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Tasks
</h2>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-black dark:hover:bg-zinc-300"
>
{showForm ? 'cancel' : 'new task'}
</button>
</div>
{error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
{showForm && (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
task name
</label>
<input
type="text"
value={form.task_name}
onChange={(e) => setForm({ ...form, task_name: e.target.value })}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
placeholder="e.g., Book cheapest flight to Paris"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
description
</label>
<textarea
value={form.task_description}
onChange={(e) => setForm({ ...form, task_description: e.target.value })}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
placeholder="User should find and book the cheapest available flight..."
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
definition of done
</label>
<textarea
value={form.task_def_of_done}
onChange={(e) => setForm({ ...form, task_def_of_done: e.target.value })}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
placeholder="Booking is completed and confirmation page is shown"
rows={2}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
{loading ? 'creating...' : 'create task'}
</button>
</form>
)}
<div className="space-y-2">
{tasks.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
no tasks yet
</p>
) : (
tasks.map((task) => (
<div
key={task.id}
onClick={() => onTaskSelect?.(task.id)}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
selectedTaskId === task.id
? 'border-zinc-900 bg-zinc-50 dark:border-zinc-100 dark:bg-zinc-900'
: 'border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700'
}`}
>
<h3 className="font-medium text-zinc-900 dark:text-zinc-100">
{task.task_name}
</h3>
{task.task_description && (
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{task.task_description}
</p>
)}
{task.task_def_of_done && (
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
done: {task.task_def_of_done}
</p>
)}
</div>
))
)}
</div>
</div>
);
};

View File

@@ -1,6 +1,7 @@
'use client';
import type { EventName } from '@/lib/events';
import type { Flight } from '@/lib/airline-utils';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
@@ -11,32 +12,17 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
document.dispatchEvent(e);
};
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
type FareRule = 'flexible' | 'standard' | 'basic';
interface Flight {
id: string;
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabinClass: CabinClass;
fareRule: FareRule;
refundable: boolean;
basePrice: number;
}
export default function AirlineCard({ flight }: { flight: Flight }) {
const durationRef = useHoverTracking({
eventName: 'hover_over_title',
productId: flight.id,
metadata: { elementText: flight.duration },
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: flight.id,
metadata: { elementText: 'price' },
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
});
const handleCardClick = () => {
@@ -44,7 +30,9 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
cabinClass: flight.cabinClass,
fareRule: flight.fareRule,
price: flight.basePrice,
dateIndex: flight.dateIndex,
});
window.location.href = `/airline/products/${flight.id}`;
};
return (

View File

@@ -0,0 +1,75 @@
'use client';
import type { Flight } from '@/lib/airline-utils';
interface AirlineDetailsProps {
product: Flight;
onAddToCart: () => void;
addedToCart: boolean;
}
export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) {
return (
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
{/* Image Section */}
<div className="w-full lg:w-1/3 bg-gray-100 rounded-lg aspect-square flex items-center justify-center shrink-0">
<span className="text-gray-400 text-lg font-medium">Flight Image</span>
</div>
{/* Details Section */}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start border-b pb-6 mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-1">{product.flightType}</h1>
<p className="text-lg text-gray-500">{product.cabinClass} Class</p>
</div>
<div className="text-right">
<p className="text-4xl font-bold text-gray-900">${product.basePrice}</p>
{product.refundable && (
<span className="inline-block mt-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-xs font-medium">
Refundable
</span>
)}
</div>
</div>
<div className="flex items-center justify-between mb-10">
<div className="text-center min-w-[100px]">
<p className="text-3xl font-bold text-gray-900">{product.departure.time}</p>
<p className="text-sm text-gray-500 font-medium mt-1">{product.departure.airport}</p>
</div>
<div className="flex-1 px-8 flex flex-col items-center">
<p className="text-sm text-gray-500 mb-2">{product.duration}</p>
<div className="w-full h-0.5 bg-gray-200 relative flex items-center justify-center">
<div className="absolute w-3 h-3 bg-gray-400 rounded-full"></div>
</div>
<p className="text-sm text-gray-500 mt-2">
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
</p>
</div>
<div className="text-center min-w-[100px]">
<p className="text-3xl font-bold text-gray-900">{product.arrival.time}</p>
<p className="text-sm text-gray-500 font-medium mt-1">{product.arrival.airport}</p>
</div>
</div>
<div className="mt-auto flex items-center justify-between pt-6 border-t">
<div className="text-gray-600">
<span className="font-bold text-gray-900">{product.availability}</span> seats remaining
<span className="mx-2"></span>
{product.fareRule}
</div>
<button
onClick={onAddToCart}
disabled={addedToCart}
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
>
{addedToCart ? 'In Cart' : 'Add to Cart'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
import { dateToDaysFromToday } from '@/lib/airline-utils';
type TripType = 'roundtrip' | 'oneway' | 'multicity';
@@ -19,6 +21,7 @@ const LocationIcon = () => (
);
export default function AirlineHero() {
const router = useRouter();
const [tripType, setTripType] = useState<TripType>('roundtrip');
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
@@ -28,7 +31,23 @@ export default function AirlineHero() {
const handleSearch = (e: FormEvent) => {
e.preventDefault();
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
const params = new URLSearchParams();
if (departDate) {
const daysOffset = dateToDaysFromToday(departDate);
params.set('dateIndex', daysOffset.toString());
}
if (origin) params.set('origin', origin);
if (destination) params.set('destination', destination);
if (tripType !== 'roundtrip') params.set('tripType', tripType);
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
params.set('adults', passengers.adults.toString());
params.set('children', passengers.children.toString());
params.set('infants', passengers.infants.toString());
router.push(`/airline/products?${params.toString()}`);
};
const totalPax = passengers.adults + passengers.children + passengers.infants;

View File

@@ -1,6 +1,7 @@
'use client';
import type { EventName } from '@/lib/events';
import type { Hotel } from '@/lib/hotel-utils';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
@@ -11,18 +12,6 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
document.dispatchEvent(e);
};
interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
}
const AmenityIcon = ({ name }: { name: string }) => {
const iconMap: Record<string, string> = {
wifi: 'Wi-Fi',
@@ -39,13 +28,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
const titleRef = useHoverTracking({
eventName: 'hover_over_title',
productId: hotel.id,
metadata: { elementText: hotel.name },
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: hotel.id,
metadata: { elementText: 'price' },
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
});
const handleCardClick = () => {
@@ -53,7 +42,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
roomType: hotel.roomType,
price: hotel.pricePerNight,
nights: hotel.nights,
dateIndex: hotel.dateIndex,
});
window.location.href = `/hotel/products/${hotel.id}`;
};
return (

View File

@@ -0,0 +1,74 @@
'use client';
import type { Hotel } from '@/lib/hotel-utils';
interface HotelDetailsProps {
product: Hotel;
onAddToCart: () => void;
addedToCart: boolean;
}
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
return (
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
{/* Image Section - Larger and cleaner */}
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
</div>
{/* Details Section - Full height/width usage */}
<div className="flex-1 flex flex-col">
<div className="border-b pb-6 mb-6">
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
<p className="text-xl text-gray-500">{product.roomType}</p>
</div>
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-in</h3>
<p className="text-lg text-gray-700">{product.checkIn}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-out</h3>
<p className="text-lg text-gray-700">{product.checkOut}</p>
</div>
</div>
<div className="mb-8">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-3">Amenities</h3>
<div className="flex flex-wrap gap-3">
{product.amenities.map(a => (
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
{a}
</span>
))}
</div>
</div>
{product.refundable && (
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
<span className="font-medium">Free cancellation available</span>
</div>
)}
<div className="mt-auto pt-6 border-t flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
<span className="text-gray-500">/ {product.nights} nights</span>
</div>
</div>
<button
onClick={onAddToCart}
disabled={addedToCart}
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
>
{addedToCart ? 'In Cart' : 'Add to Cart'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
import { dateToDaysFromToday } from '@/lib/hotel-utils';
const LocationIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -11,14 +13,25 @@ const LocationIcon = () => (
);
export default function HotelHero() {
const router = useRouter();
const [destination, setDestination] = useState('');
const [checkIn, setCheckIn] = useState('');
const [checkOut, setCheckOut] = useState('');
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
const handleSearch = (e: FormEvent) => {
e.preventDefault();
console.log({ destination, checkIn, checkOut, guests });
const params = new URLSearchParams();
if (checkIn) {
const daysOffset = dateToDaysFromToday(checkIn);
params.set('dateIndex', daysOffset.toString());
}
if (destination) params.set('destination', destination);
params.set('adults', guests.adults.toString());
params.set('rooms', guests.rooms.toString());
router.push(`/hotel/products?${params.toString()}`);
};
return (
@@ -26,16 +39,16 @@ export default function HotelHero() {
<div className="w-full max-w-4xl px-4">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Find your perfect stay
Find your perfect room
</h1>
<p className="text-lg">
Search hotels, compare prices, and book with confidence
Search rooms, compare prices, and book with confidence
</p>
</div>
<form onSubmit={handleSearch} className="search-form">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="sm:col-span-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<Label htmlFor="destination">Where to?</Label>
<Input
type="text"
@@ -49,7 +62,7 @@ export default function HotelHero() {
</div>
<div>
<Label htmlFor="checkIn">Check-in</Label>
<Label htmlFor="checkIn">Date (1 night stay)</Label>
<DateInput
id="checkIn"
value={checkIn}
@@ -59,43 +72,27 @@ export default function HotelHero() {
</div>
<div>
<Label htmlFor="checkOut">Check-out</Label>
<DateInput
id="checkOut"
value={checkOut}
onChange={(e) => setCheckOut(e.target.value)}
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-4">
<Label htmlFor="guests">Guests & Rooms</Label>
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
<Label htmlFor="guests">Guests</Label>
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
<DropdownCounter
label="Adults"
value={guests.adults}
min={1}
onChange={(v) => setGuests({ ...guests, adults: v })}
/>
<DropdownCounter
label="Rooms"
value={guests.rooms}
min={1}
onChange={(v) => setGuests({ ...guests, rooms: v })}
/>
</Dropdown>
</div>
<div className="sm:col-span-2 lg:col-span-4">
<div className="sm:col-span-2 lg:col-span-3">
<Button type="submit" fullWidth>
Search Hotels
Search Rooms
</Button>
</div>
</div>
</form>
<div className="mt-6 text-center text-sm">
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export interface CartItem {
id: string;
type: 'hotel' | 'airline';
name: string;
price: number;
metadata: Record<string, unknown>;
dateIndex: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
itemCount: number;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
const CART_KEY = 'phantom_cart';
export const CartProvider = ({ children }: { children: ReactNode }) => {
const [items, setItems] = useState<CartItem[]>([]);
const [loaded, setLoaded] = useState(false);
// load cart from sessionStorage on mount
useEffect(() => {
const stored = sessionStorage.getItem(CART_KEY);
if (stored) {
try {
setItems(JSON.parse(stored));
} catch (e) {
console.error('[CART_LOAD]', e);
}
}
setLoaded(true);
}, []);
// persist to sessionStorage whenever cart changes
useEffect(() => {
if (!loaded) return;
sessionStorage.setItem(CART_KEY, JSON.stringify(items));
}, [items, loaded]);
const addItem = (item: CartItem) => {
setItems(prev => {
// prevent duplicates
if (prev.find(i => i.id === item.id)) return prev;
return [...prev, item];
});
};
const removeItem = (id: string) => {
setItems(prev => prev.filter(i => i.id !== id));
};
const clearCart = () => {
setItems([]);
};
return (
<CartContext.Provider value={{ items, addItem, removeItem, clearCart, itemCount: items.length }}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import '@/lib/experiments' // ensure experiments lib is loaded
import '@/lib/experiments'
import type { EventName } from '@/lib/events';
const fetchSessionId = async (): Promise<string> => {
@@ -21,10 +21,14 @@ const track = async (ev: {
metadata?: Record<string, unknown>;
}) => {
try {
const experimentId = localStorage.getItem('phantom_experiment_id');
await fetch('/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
body: JSON.stringify({
...ev,
experimentId: experimentId || undefined,
}),
});
} catch (err) {
console.error('track failed:', err);

View File

@@ -0,0 +1,75 @@
export interface AirlineProduct {
id: string;
flight_type: string;
date_index: number;
metadata: {
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabin_class: string;
fare_rule: string;
refundable: boolean;
total?: number;
base_price: number;
};
availability: number;
}
export interface Flight {
id: string;
flightType: string;
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabinClass: string;
fareRule: string;
refundable: boolean;
basePrice: number;
dateIndex: number;
availability: number;
}
const EPOCH = new Date(0);
export const transformProduct = (p: AirlineProduct): Flight => {
const { id, flight_type, date_index, metadata, availability } = p;
return {
id,
flightType: flight_type,
departure: metadata.departure,
arrival: metadata.arrival,
duration: metadata.duration,
stops: metadata.stops,
cabinClass: metadata.cabin_class,
fareRule: metadata.fare_rule,
refundable: metadata.refundable,
basePrice: metadata.base_price,
dateIndex: date_index,
availability,
};
};
// convert date string to days from today
export const dateToDaysFromToday = (dateStr: string): number => {
const target = new Date(dateStr);
target.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.floor((target.getTime() - today.getTime()) / 86400000);
};
// convert date string to date_index (days since epoch)
export const dateToIndex = (dateStr: string): number => {
const d = new Date(dateStr);
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
};
// get current date_index
export const todayIndex = (): number => {
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
};

View File

@@ -0,0 +1,71 @@
export interface HotelProduct {
id: string;
room_type: string;
date_index: number;
metadata: {
amenities?: string[];
total?: number;
image_url?: string;
base_price?: number;
name?: string;
refundable?: boolean;
};
availability: number;
}
export interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
dateIndex: number;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
}
const EPOCH = new Date(0);
export const transformProduct = (p: HotelProduct): Hotel => {
const { id, room_type, date_index, metadata } = p;
const checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
const nights = 1;
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
return {
id,
name: metadata?.name || room_type,
roomType: room_type,
checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
dateIndex: date_index,
amenities: metadata?.amenities || [],
refundable: metadata?.refundable || false,
pricePerNight: metadata?.base_price || 100,
nights,
};
};
// convert date string to days from today
export const dateToDaysFromToday = (dateStr: string): number => {
const target = new Date(dateStr);
target.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.floor((target.getTime() - today.getTime()) / 86400000);
};
// convert date string to date_index (days since epoch)
export const dateToIndex = (dateStr: string): number => {
const d = new Date(dateStr);
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
};
// get current date_index
export const todayIndex = (): number => {
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
};

View File

@@ -0,0 +1,25 @@
import { HotelProduct, Hotel, transformProduct as transformHotel } from './hotel-utils';
import { AirlineProduct, Flight, transformProduct as transformFlight } from './airline-utils';
export type Product = Hotel | Flight;
export type ProductRaw = HotelProduct | AirlineProduct;
export const isHotelProduct = (p: ProductRaw): p is HotelProduct => {
return 'room_type' in p;
};
export const isAirlineProduct = (p: ProductRaw): p is AirlineProduct => {
return 'flight_type' in p;
};
export const transformProduct = (p: ProductRaw): Product => {
if (isHotelProduct(p)) {
return transformHotel(p);
}
return transformFlight(p);
};
export const getProductType = (p: Product): 'hotel' | 'airline' => {
if ('roomType' in p) return 'hotel';
return 'airline';
};

View File

@@ -10,6 +10,8 @@ export function proxy(req: NextRequest) {
pathname.startsWith('/admin') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/static') ||
pathname.startsWith('/start-task') ||
pathname.startsWith('/cart') ||
pathname.includes('.')
// TODO: add robots.txt and sitemap.xml if needed here
) {

View File

@@ -0,0 +1,10 @@
import { createBrowserClient } from "@supabase/ssr";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const createClient = () =>
createBrowserClient(
supabaseUrl!,
supabaseKey!,
);

View File

@@ -0,0 +1,37 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const createClient = (request: NextRequest) => {
// Create an unmodified response
let supabaseResponse = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
supabaseUrl!,
supabaseKey!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
},
);
return supabaseResponse
};

View File

@@ -0,0 +1,27 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const createClient = (cookieStore: ReadonlyRequestCookies) => {
return createServerClient(
supabaseUrl!,
supabaseKey!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
} catch {
// `setAll` called from Server Component - ignored if middleware handles session refresh
}
},
},
},
);
};