Compare commits

..

4 Commits

78 changed files with 600 additions and 4788 deletions

View File

@@ -1,18 +1,5 @@
# Network configuration
HOSTNAME=localhost # hostname for service discovery across docker network
HOSTNAME=localhost
# Application configuration
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
# Backend service
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
# Service ports - used by docker-compose and service communication
BACKEND_PORT=5000 # backend server port for kafka ingestion API
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
KAFKA_PORT=9092 # kafka broker port for event streaming
REDIS_PORT=6377 # redis port for worker queue and caching
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
# PORTS
KAFKA_PORT=9092
REDIS_PORT=6377

View File

@@ -1,30 +0,0 @@
name: Run Tests
on:
push:
paths:
- 'experiments/**'
- 'backend/**'
- 'requirements.txt'
- '.github/workflows/pytest.yml'
pull_request:
paths:
- 'experiments/**'
- 'backend/**'
- 'requirements.txt'
- '.github/workflows/pytest.yml'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'
- name: Install dependencies
run: |
python -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
- name: Run tests
run: .venv/bin/pytest -v

8
.gitignore vendored
View File

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

View File

@@ -4,10 +4,6 @@ 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
@@ -39,14 +35,5 @@ clean:
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
rm -rf paper/$(BUILDDIR)/*
$(VENV):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
install: $(VENV)
$(PIP) install -r requirements.txt
test: $(VENV)
$(PYTEST) -v
.PHONY: all pdf clean watch run.webapp install test
.PHONY: all pdf clean watch run.webapp

View File

@@ -1,5 +1 @@
[![Build PDF](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml/badge.svg)](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
- https://phantom-hotel.vercel.app/
- https://phantom-airline.vercel.app/

View File

@@ -1,190 +0,0 @@
# boilerplate code
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, Any
import uvicorn
import os
import json
from datetime import datetime
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
from kafka.admin import NewTopic
from kafka.errors import TopicAlreadyExistsError
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
# kafka producer - lazy init
_producer: Optional[KafkaProducer] = None
def get_producer() -> KafkaProducer:
global _producer
if _producer is None:
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
broker = f'{host}:{port}' if port else host
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
_producer = KafkaProducer(
bootstrap_servers=[broker],
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
key_serializer=lambda k: k.encode('utf-8') if k else None,
acks=1,
retries=3,
max_in_flight_requests_per_connection=5,
request_timeout_ms=30000,
api_version_auto_timeout_ms=10000,
max_block_ms=5000, # don't block send() for more than 5s
)
print(f"[KAFKA_INIT] Producer created successfully")
return _producer
class EventPayload(BaseModel):
sessionId: str
experimentId: Optional[str] = None
eventName: str
page: str
productId: Optional[str] = None
metadata: Optional[dict[str, Any]] = None
storeMode: str
userAgent: Optional[str] = None
ts: Optional[str] = None
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""create kafka topics on startup"""
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
broker = f'{host}:{port}'
try:
print(f"[STARTUP] Creating Kafka topics on {broker}")
admin = KafkaAdminClient(
bootstrap_servers=[broker],
request_timeout_ms=10000,
)
topics = [
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1)
]
admin.create_topics(new_topics=topics, validate_only=False)
print(f"[STARTUP] Topics created successfully")
admin.close()
except TopicAlreadyExistsError:
print(f"[STARTUP] Topics already exist, skipping creation")
except Exception as e:
print(f"[STARTUP] Failed to create topics: {e}")
print(f"[STARTUP] Will rely on auto-creation on first message")
@app.get("/health")
async def health():
kafka_status = "unknown"
try:
producer = get_producer()
# attempt to get cluster metadata to verify connection
producer.bootstrap_connected()
kafka_status = "connected"
except Exception as e:
kafka_status = f"error: {str(e)}"
return {
"status": "healthy",
"kafka": kafka_status,
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
}
@app.post("/api/kafka/ingest")
async def ingest_logs(event: EventPayload):
try:
if not event.ts:
event.ts = datetime.utcnow().isoformat() + 'Z'
producer = get_producer()
future = producer.send(
'user-interactions',
key=event.sessionId,
value=event.model_dump()
)
# add callback for error logging but don't block
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
return {"success": True}
except Exception as e:
import traceback
print(f"[ERROR] {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/kafka/dump")
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))
if __name__ == "__main__":
PORT=int(os.getenv("BACKEND_PORT", 5000))
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)

View File

@@ -1,5 +0,0 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
kafka-python==2.0.2
pydantic==2.5.0
python-dotenv==1.0.0

View File

@@ -1,34 +1,15 @@
services:
backend:
container_name: "PHANTOM-backend"
build:
context: .
dockerfile: docker/backend.Dockerfile
ports:
- "${BACKEND_PORT:-5000}:5000"
environment:
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
depends_on:
- kafka
restart: unless-stopped
redis:
container_name: "PHANTOM-redis"
build:
context: ./docker
dockerfile: Redis.dockerfile
image: redis:7-alpine
ports:
- "${REDIS_PORT:-6378}:6379"
volumes:
- phantom_redis_data:/data
restart: unless-stopped
zookeeper:
container_name: "PHANTOM-zookeeper"
build:
context: ./docker
dockerfile: Zookeeper.dockerfile
image: confluentinc/cp-zookeeper:latest
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ports:
@@ -36,9 +17,7 @@ services:
kafka:
container_name: "PHANTOM-kafka"
build:
context: ./docker
dockerfile: Kafka.dockerfile
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
environment:
@@ -57,9 +36,7 @@ services:
redpanda-console:
container_name: "PHANTOM-redpanda-console"
build:
context: ./docker
dockerfile: RedpandaConsole.dockerfile
image: docker.redpanda.com/redpandadata/console:latest
depends_on:
- kafka
environment:

View File

@@ -1,7 +0,0 @@
FROM confluentinc/cp-kafka:7.5.0
# Expose Kafka ports
# 9092: External client connections
# 29092: Internal broker communication
# 9999: JMX monitoring port
EXPOSE 9092 29092 9999

View File

@@ -1,4 +0,0 @@
FROM redis:7-alpine
# Expose Redis port
EXPOSE 6379

View File

@@ -1,4 +0,0 @@
FROM docker.redpanda.com/redpandadata/console:latest
# Expose Redpanda Console web UI port
EXPOSE 8080

View File

@@ -1,4 +0,0 @@
FROM confluentinc/cp-zookeeper:latest
# Expose Zookeeper client port
EXPOSE 2181

View File

@@ -1,12 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY backend/server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/server/app.py .
EXPOSE 5000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]

View File

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

View File

@@ -1,44 +0,0 @@
from .base import Agent as BaseAgent
from browser_use import Browser, Agent, ChatOpenAI
from enum import Enum
class AgentTypes(str, Enum):
GENERIC_BROWSER_USE_AGENT = "generic_browser_use_agent"
def _build_prompt(goal : str, environment_url : str) -> str:
#TODO: Improve prompt engineering here and experiment with
return f"""You are an autonomous agent tasked with achieving the following goal: {goal}
You have access to a web browser to interact with the environment at {environment_url}.
Use the browser to navigate, gather information, and perform actions necessary to accomplish your goal.
Be thorough and ensure you complete the task fully."""
class GenericBrowserUseAgent(BaseAgent):
def __init__(self,
goal: str,
url: str = "http://localhost:3000",
timeout: int = 300,
llm_model: str = "gpt-5-mini",
headless: bool = True):
super().__init__(goal, url, timeout)
self.llm_model = ChatOpenAI(model=llm_model)
self.browser = Browser(headless=headless)
self.agent = Agent(task=_build_prompt(goal, url),
llm=self.llm_model,
browser=self.browser)
async def act(self) -> str:
self.result = await self.agent.run()
# https://github.com/browser-use/browser-use/blob/main/browser_use/agent/views.py#L301
return self.result.final_result()
def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
if agent_type == AgentTypes.GENERIC_BROWSER_USE_AGENT:
return GenericBrowserUseAgent(**kwargs)
else:
raise ValueError(f"Unknown agent type: {agent_type}")
if __name__ == "__main__":
import asyncio
JTBD= "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

@@ -1,19 +0,0 @@
from abc import ABC, abstractmethod
from typing import Optional
class Agent(ABC):
"""Base interface for browser automation agents"""
def __init__(self, goal: str, url: str = "http://localhost:3000", timeout: int = 300):
self.goal = goal
self.url = url
self.timeout = timeout
self.result: Optional[str] = None
@abstractmethod
async def act(self) -> str:
"""Execute goal and return result text"""
pass
def final_result(self) -> Optional[str]:
return self.result

View File

@@ -1,30 +0,0 @@
import pytest
import asyncio
from experiments.agents.agent import get_agent, AgentTypes
import os
def test_agent_init():
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="test", url="http://example.com", timeout=100)
assert agent.goal == "test"
assert agent.url == "http://example.com"
assert agent.timeout == 100
def test_invalid_agent():
with pytest.raises(ValueError):
get_agent("invalid", goal="test")
@pytest.mark.asyncio
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
async def test_agent_execution():
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="get page title", url="https://example.com", timeout=60)
result = await agent.act()
assert result
assert agent.final_result()
assert agent.final_result().history[-1].result[-1].is_done == True
assert isinstance(result, str)
assert "example" in result.lower()
assert len(result) > 0

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +0,0 @@
import pandas as pd
import json
import numpy as np
import os
import requests
from dotenv import load_dotenv
from sklearn.base import BaseEstimator, TransformerMixin
from supabase import create_client, Client
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)
def get_data_from_kafka() -> pd.DataFrame:
"""fetch all events from backend dump endpoint"""
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'])
return df
def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame:
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
def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame:
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
# we want metadata schema to create product specific event names
# only create price buckets if we have enough unique prices
if df["metadata_price"].notnull().sum() > 0:
try:
price_buckets = pd.qcut(
df["metadata_price"],
q=N_PRICE_BUCKETS,
labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)],
duplicates='drop' # handle duplicate bin edges
)
except ValueError:
# fallback: if still not enough unique values, use cut with fixed ranges or just use raw price
price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "")
else:
price_buckets = pd.Series([""] * len(df), index=df.index)
# metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name
# TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page
df["metadata_schema"] = np.where(
df["productId"].notnull() & df["metadata_price"].notnull(),
"_" + df["productId"].astype(str) + "@" + price_buckets.astype(str),
""
)
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
return df
def extract() -> pd.DataFrame:
df = get_data_from_kafka()
df = join_with_experiments(df)
df = augment_event_titles(df)
return df
class DataExtractor(BaseEstimator, TransformerMixin):
def fit(self, X=None, y=None):
return self
def transform(self, X=None):
return extract()
if __name__ == "__main__":
df = extract()
print(df.head())
print(df.tail())
print(df.info())

View File

@@ -1,158 +0,0 @@
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
def build_transition_prob_matrix(df: pd.DataFrame):
df = df.dropna(subset=['eventName'])
events = df['eventName'].tolist()
labels = pd.Index(events).unique().tolist()
idx = {e:i for i,e in enumerate(labels)}
M = np.zeros((len(labels), len(labels)), dtype=float)
for a, b in zip(events, events[1:]):
M[idx[a], idx[b]] += 1
row_sums = M.sum(axis=1, keepdims=True)
with np.errstate(divide='ignore', invalid='ignore'):
P = np.divide(M, row_sums, where=row_sums>0) # row-normalized
return P, labels
# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b
from graphviz import Digraph
import numpy as np
import pandas as pd
def _as_prob_df(matrix, labels=None):
"""Return a square DataFrame with index=columns=labels."""
if isinstance(matrix, pd.DataFrame):
# Ensure square and aligned
assert (matrix.index == matrix.columns).all(), "Index/columns must match."
return matrix
matrix = np.asarray(matrix, dtype=float)
assert matrix.shape[0] == matrix.shape[1], "Matrix must be square."
if labels is None:
raise ValueError("labels are required when matrix is not a DataFrame")
assert len(labels) == matrix.shape[0], "labels length must match matrix size."
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):
"""Build weighted edges > threshold."""
edges = []
for src in P.index:
for dst in P.columns:
w = float(P.loc[src, dst])
if w > threshold:
edges.append((str(src), str(dst), f"{w:.{round_digits}f}"))
return edges
def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt="svg", view=False):
"""
fname: output file stem (no extension)
matrix: NumPy array or pandas DataFrame of transition PROBABILITIES
ls_index: ordered labels (required if matrix is not a DataFrame)
threshold: hide edges with weight <= threshold
fmt: 'svg'|'png'|'pdf' etc.
view: open after rendering
"""
P = _as_prob_df(matrix, labels=ls_index)
edges = _df_to_edgelist(P, threshold=threshold)
g = Digraph(format=fmt)
g.attr(rankdir="LR", size="30")
g.attr("node", shape="circle")
# ensure isolated nodes appear
for node in P.index:
g.node(str(node), width="1", height="1")
for src, dst, label in edges:
g.edge(src, dst, label=label)
g.render(fname, view=view, cleanup=True)
return g
class TransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
def __init__(self, threshold=0.0):
self.threshold = threshold
self.P_ = None
self.labels_ = None
def fit(self, X: pd.DataFrame, y=None):
P, labels = build_transition_prob_matrix(X)
self.P_ = P
self.labels_ = labels
return self
def transform(self, X: pd.DataFrame = None):
return self.P_, self.labels_
def render(self, fname: str, fmt="svg", view=False):
if self.P_ is None or self.labels_ is None:
raise ValueError("Transformer has not been fitted yet.")
return render_graph(
fname,
self.P_,
ls_index=self.labels_,
threshold=self.threshold,
fmt=fmt,
view=view
)
class SessionTransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
def __init__(self, threshold=0.0, session_col='sessionId'):
self.threshold = threshold
self.session_col = session_col
self.session_matrices_ = None
def fit(self, X: pd.DataFrame, y=None):
if self.session_col not in X.columns:
raise ValueError(f"Column '{self.session_col}' not found in DataFrame")
session_matrices = {}
for session_id, grp in X.groupby(self.session_col):
if len(grp) > 1: # need at least 2 events for transitions
P, labels = build_transition_prob_matrix(grp)
session_matrices[session_id] = {'matrix': P, 'labels': labels}
self.session_matrices_ = session_matrices
return self
def transform(self, X: pd.DataFrame = None):
if self.session_matrices_ is None:
raise ValueError("Transformer has not been fitted yet.")
return pd.Series(self.session_matrices_)
def render_session(self, session_id: str, fname: str, fmt="svg", view=False):
if self.session_matrices_ is None:
raise ValueError("Transformer has not been fitted yet.")
if session_id not in self.session_matrices_:
raise ValueError(f"Session '{session_id}' not found in fitted data.")
sess_data = self.session_matrices_[session_id]
return render_graph(
fname,
sess_data['matrix'],
ls_index=sess_data['labels'],
threshold=self.threshold,
fmt=fmt,
view=view
)
if __name__ == "__main__":
# Example usage
data = {
'eventName': [
'A', 'B', 'A', 'C', 'B', 'A', 'A', 'C', 'B', 'C',
'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'
]
}
df = pd.DataFrame(data)
transformer = TransitionProbMatrixTransformer(threshold=0.1)
transformer.fit(df)
P, labels = transformer.transform(None)
print("Transition Probability Matrix:")
print(pd.DataFrame(P, index=labels, columns=labels))
# Render the graph
transformer.render("transition_graph", fmt="svg", view=False)

View File

@@ -1,15 +0,0 @@
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from extract import DataExtractor
from mapping import SessionTransitionProbMatrixTransformer, render_graph
if __name__ == "__main__":
steps = [
('data_extraction', DataExtractor()),
#('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)),
]
pipeline = Pipeline(steps)
result = pipeline.fit_transform(None)
print(result)
print(result.info())

View File

@@ -16,11 +16,10 @@ 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{${escaped_path}}" >> "$OUTPUT_FILE"
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
echo "\\subsection{${relpath}}" >> "$OUTPUT_FILE"
echo "\\begin{lstlisting}[caption={${relpath}}]" >> "$OUTPUT_FILE"
cat "$filepath" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"

View File

@@ -22,3 +22,4 @@
(TeX-add-symbols
'("footnotetextcopyrightpermission" 1)))
:latex)

View File

@@ -0,0 +1,98 @@
@phdthesis{,
abstract = {Algorithmic pricing is an emerging business practice that uses computational algorithms to determine
the prices of products and services based on a number of dynamic factors. The aim of this thesis is to
draw attention to the existence of these business practices, and the ethical and social implications that
derive from them, and then focus on what could be effective solutions to increase the well-being of
the community.
In Chapter 2 of the thesis, a general introduction to the topic will be made, starting from its history
and its evolution over the years; Chapter 3 will examine the different types of pricing algorithms.
Subsequently, in Chapter 4 we will analyze the sectors in which they are most applicable, and the
relative advantages and disadvantages they bring with them, with a critical analysis of the trade-offs
generated. The effect of algorithmic pricing on competition will be studied, considering how the
ability of algorithms to adapt quickly to market conditions can foster anti-competitive practices, such
as price discrimination. Later, in Chapter 5, we will look at the issue of price transparency and how
the opacity of algorithms can make it difficult for consumers to understand the pricing process and
assess whether they are receiving fair treatment.
To address these ethical issues, several possible solutions will be brought to light, described in
Chapter 6, which will focus on the role of the government, as a regulatory, of the end consumer, who
must be encouraged to educate and inform himself about the use of these practices, and of the
company, as responsible for making its customers aware and acting in compliance with government
laws, for fair and non-discriminatory use.},
author = {Fabio Salassa and Paolo Pautassi},
school = {Politecnico di Torino},
title = {Politecnico di Torino Algorithmic Pricing in the digital age "Ethical considerations on its economic and social implications, and an analysis of possible solutions to overcome its critical issues" Tutor: Candidate},
url = {https://webthesis.biblio.polito.it/secure/31375/1/tesi.pdf}
}
@inproceedings{Mueller2019,
author = {Jonas W Mueller and Vasilis Syrgkanis and Matt Taddy},
booktitle = {Advances in Neural Information Processing Systems 32 (NeurIPS 2019)},
pages = {15442-15452},
title = {Low-Rank Bandit Methods for High-Dimensional Dynamic Pricing},
url = {https://proceedings.neurips.cc/paper/2019/file/0a3df70393993583a13c0dd6686f3f32-Paper.pdf},
year = {2019}
}
@article{Prez-Ricardo2025,
abstract = {The study aims to explore tourists' booking intentions by analyzing the price elasticity of demand in tourist accommodations. This analysis should reveal how changes in price affect booking behavior across different customer segments, using online booking records. A dataset was compiled from 106 hotels in Malaga, Spain, comprising 27,910 online bookings sourced exclusively from hotel websites. To understand the price elasticity of demand, a simple log-log regression was applied, segmenting the data based on key revenue-related variables. Subsequently, a cluster segmentation was performed using the Elbow method and K-means algorithm to identify distinct market segments. The findings highlighted that Family Travelers and Short Stay Travelers segments exhibited elastic demand, indicating higher sensitivity to price fluctuations. In contrast, Early Bookers and Mid-Season Long Stayers demonstrated inelastic demand, with lower responsiveness to changes in tourist accommodation prices. The number of variables analyzed in this study, along with the cluster analysis, represent a novelty and contribute to the existing literature on market segmentation and price elasticity of demand. This integration enriches both fields of research, offering mutual benefits and deeper insights that enhance the understanding of booking intention and pricing strategies.},
author = {Elizabeth del Carmen Pérez-Ricardo and Josefa García-Mestanza},
doi = {10.1016/j.iedeen.2025.100271},
issn = {24448834},
issue = {1},
journal = {European Research on Management and Business Economics},
keywords = {Booking intention,Price elasticity,Tourist segmentation},
month = {1},
publisher = {European Academy of Management and Business Economics},
title = {Exploring booking intentions through price elasticity of demand in tourism accommodations using large-scale data analytics},
volume = {31},
year = {2025}
}
@article{ArnoudVdenBoer2015,
author = {Arnoud V. den Boer},
doi = {10.1016/j.sorms.2015.03.001},
issue = {1},
journal = {Surveys in Operations Research and Management Science},
month = {6},
pages = {1-18},
title = {Dynamic pricing and learning: Historical origins, current research, and new directions},
volume = {20},
url = {https://www.sciencedirect.com/science/article/pii/S1876735415000021},
year = {2015}
}
@article{Iliou2021,
author = {Christos Iliou and Theodoros Kostoulas and Theodora Tsikrika and Vasilis Katos and Stefanos Vrochidis and Ioannis Kompatsiaris},
doi = {10.1145/3447815},
issue = {3},
journal = {Digital Threats: Research and Practice},
pages = {1-26},
title = {Detection of Advanced Web Bots by Combining Web Logs with Mouse Behavioural Biometrics},
volume = {2},
url = {https://dl.acm.org/doi/10.1145/3447815},
year = {2021}
}
@article{Amjad2017,
abstract = { In this paper, the question of interest is estimating true demand of a product at a given store location and time period in the retail environment based on a single noisy and potentially censored observation. To address this question, we introduce a %non-parametric framework to make inference from multiple time series. Somewhat surprisingly, we establish that the algorithm introduced for the purpose of "matrix completion" can be used to solve the relevant inference problem. Specifically, using the Universal Singular Value Thresholding (USVT) algorithm [7], we show that our estimator is consistent: the average mean squared error of the estimated average demand with respect to the true average demand goes to 0 as the number of store locations and time intervals increase to $\infty$. We establish naturally appealing properties of the resulting estimator both analytically as well as through a sequence of instructive simulations. Using a real dataset in retail (Walmart), we argue for the practical relevance of our approach. },
author = {Muhammad J. Amjad and Devavrat Shah},
doi = {10.1145/3154489},
issue = {2},
journal = {Proceedings of the ACM on Measurement and Analysis of Computing Systems},
month = {12},
pages = {1-28},
publisher = {Association for Computing Machinery (ACM)},
title = {Censored Demand Estimation in Retail},
volume = {1},
url = {https://par.nsf.gov/servlets/purl/10066022},
year = {2017}
}
@article{Calvano2018,
author = {Emilio Calvano and Giacomo Calzolari and Vincenzo Denicolo and Sergio Pastorello},
doi = {10.2139/ssrn.3304991},
journal = {SSRN Electronic Journal},
title = {Artificial Intelligence, Algorithmic Pricing and Collusion},
url = {https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3304991},
year = {2018}
}
@misc{gha_ffary_day_2025_amazon_perplexit,
author = {Shirin Ghaffary and Matt Day},
note = {Updated 2025-11-05},
title = {Amazon Sues to Stop Perplexity From Using AI Tool to Buy Stuff},
url = {https://www.bloomberg.com/news/articles/2025-11-04/amazon-demands-perplexity-stop-ai-agent-from-making-purchases}
}

View File

@@ -5,7 +5,7 @@
Mathematical formalization of agent-induced pricing distortions. Formal definition of potential loss mechanisms $\alpha D$
We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research.
We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research. \autocite{Mueller2019}
We gether interaction data from users interacting with a sample platform simulating a hotel/airline which generates interaction distributions $I_t = \{(p_t, q_t^\text{obs}, \pi_t)\}_{t=1}^T$

View File

@@ -29,7 +29,7 @@
}
\begin{abstract}
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
\end{abstract}
\maketitle
@@ -42,10 +42,10 @@ The primary objective of this thesis is to develop and validate pricing heuristi
\input{chapters/06-conclusion}
\printbibliography
\clearpage
\onecolumn
\printbibliography
\clearpage
\appendix
\input{../build/concatenated_code}

View File

@@ -20,10 +20,7 @@
commentstyle=\color{green!60!black},
stringstyle=\color{red},
showstringspaces=false,
captionpos=b,
inputencoding=utf8,
extendedchars=true,
literate={·}{{\textperiodcentered}}1 {}{{\textminus}}1 {}{{---}}1 {}{{--}}1
captionpos=b
}
% Use biblatex instead of natbib (acmart default)

View File

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

View File

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

View File

@@ -1,97 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Phantom Air/Hotels
## Getting Started
Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Design-Discovery
First, run the development server:
> This webapp serves two modes `{HOTEL,AIRLINE}` which are given by an env variable
The webapp should serve under the / route the landing page which for both platforms is very similar. We define a set of components like Hero, Card, Button, Link ... This we can then pass to specific components each mode might demand that makes it behave differently, hotel cards showing hotel rooms from database and airline cards showing flights from database and each fetching prices from the pricing provider with a different HTTP parameter.
- globally we define a middleware.ts which is our switcher for modes.
- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition.
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
## How to Run
```sh
# install deps
npm install
# set store mode (hotel or airline)
export STORE_MODE=hotel
# run dev server
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Server runs on `http://localhost:3000`
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Environment Variables
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `HOSTNAME` | Server hostname | `localhost` | `localhost` |
| `STORE_MODE` | Mode switch for platform | `hotel` | `hotel` or `airline` |
| `NEXT_PUBLIC_API_BASE` | Public API base URL | `http://localhost:3000` | `http://localhost:3000` |
| `NEXT_PUBLIC_APP_ENV` | Application environment | `dev` | `dev`, `prod` |
| `NEXT_PUBLIC_HOVER_THRESHOLD` | Hover dwell threshold (ms) | `1200` | `1200` |
| `BACKEND_URL` | Backend service URL | `http://localhost:5000` | `http://localhost:5000` |
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Routes
## Learn More
### Public Pages
- `/` — Landing page (mode-aware root)
- `/hotel` — Hotel mode landing
- `/hotel/products` — Hotel catalog
- `/airline` — Airline mode landing
- `/airline/products` — Flight catalog
- `/admin/experiments` — Experiment management UI
To learn more about Next.js, take a look at the following resources:
### API Routes
- `GET /api/session` — Fetch or create session, sets httpOnly cookie
- `GET /api/pricing?productId=X&sessionId=Y&experimentId=Z` — Get product price from provider
- `POST /api/ingest` — Ingest event to Kafka via backend
- `GET /api/admin/experiments` — List all experiments
- `POST /api/admin/experiments/start` — Start new experiment for session
- `POST /api/admin/experiments/stop` — Stop experiment by ID
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Event Catalog
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
## Deploy on Vercel
| Event Name | Category | Payload Example |
|------------|----------|-----------------|
| `session_start` | Session | `{ sessionId, experimentId?, storeMode, ts, page, eventName, userAgent? }` |
| `page_view` | Navigation | `{ sessionId, experimentId?, storeMode, ts, page: "/hotel", eventName: "page_view" }` |
| `view_item_page` | Discovery | `{ sessionId, storeMode, ts, page: "/hotel/products", productId: "H001", eventName: "view_item_page" }` |
| `learn_more_about_item` | Discovery | `{ sessionId, storeMode, ts, page, productId, eventName: "learn_more_about_item" }` |
| `add_item_to_cart` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "add_item_to_cart" }` |
| `remove_item` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "remove_item" }` |
| `checkout_start` | Cart | `{ sessionId, storeMode, ts, page, eventName: "checkout_start" }` |
| `purchase_complete` | Cart | `{ sessionId, storeMode, ts, page, eventName: "purchase_complete", metadata?: { total: 500 } }` |
| `search` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "search", metadata: { query: "paris" } }` |
| `filter_for_date` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_date", metadata: { from: "2025-01-15", to: "2025-01-20" } }` |
| `filter_for_amenities` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_amenities", metadata: { amenities: ["wifi", "pool"] } }` |
| `filter_for_price` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_price", metadata: { min: 100, max: 500 } }` |
| `sort_change` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "sort_change", metadata: { sort: "price_asc" } }` |
| `hover_over_title` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_title", metadata: { duration: 1500 } }` |
| `hover_over_paragraph` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_paragraph", metadata: { duration: 2000 } }` |
| `hover_over_link` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_link", metadata: { href: "/hotel/products" } }` |
| `hover_over_button` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_button", metadata: { label: "Book Now" } }` |
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Architecture
### Route Groups
- `(hotel)` — Hotel mode pages
- `(airline)` — Airline mode pages
- `api/*` — API routes (session, pricing, ingest, admin)
### Middleware Flow
1. Request arrives at Next.js
2. Session middleware checks for `phantom_session_id` cookie
3. If missing, `/api/session` mints new session + sets cookie
4. Store mode (`STORE_MODE` env) determines rendered page variant
5. Client-side components fetch pricing via `/api/pricing`
6. User interactions emit events to `/api/ingest` → Kafka
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

162
web/package-lock.json generated
View File

@@ -8,12 +8,10 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.81.1",
"kafkajs": "^2.2.4",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"zod": "^4.1.12"
"react-dom": "19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -659,97 +657,6 @@
"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",
@@ -1034,17 +941,12 @@
"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",
@@ -1065,15 +967,6 @@
"@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",
@@ -1100,15 +993,6 @@
"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",
@@ -1157,6 +1041,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/kafkajs": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -1721,37 +1614,8 @@
"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",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -8,12 +8,10 @@
"start": "next start"
},
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.81.1",
"kafkajs": "^2.2.4",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"zod": "^4.1.12"
"react-dom": "19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -1,185 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { TaskManager } from '@/components/admin/TaskManager';
import { ExperimentForm } from '@/components/admin/ExperimentForm';
type Experiment = {
id: string;
subject_name: string;
xp_human_only: boolean;
xp_market_mode: string;
created_at: string;
task?: {
id: string;
task_name: string;
};
};
export default function ExperimentsAdmin() {
const [exps, setExps] = useState<Experiment[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const fetchExps = async () => {
try {
const res = await fetch('/api/admin/experiments');
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const data = await res.json();
setExps(data.experiments || []);
} catch (err: any) {
setError(err.message);
}
};
useEffect(() => {
fetchExps();
}, []);
const handleExperimentCreated = async () => {
setShowForm(false);
setSelectedTaskId(undefined);
await fetchExps();
};
return (
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
<div className="mx-auto max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
Experiment Management
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
configure tasks and run experiments
</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* left column: task manager */}
<div className="lg:col-span-1">
<TaskManager
onTaskSelect={setSelectedTaskId}
selectedTaskId={selectedTaskId}
/>
</div>
{/* right column: experiment form + list */}
<div className="space-y-6 lg:col-span-2">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Experiments
</h2>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
{showForm ? 'hide form' : 'new experiment'}
</button>
</div>
{showForm && (
<ExperimentForm
selectedTaskId={selectedTaskId}
onSuccess={handleExperimentCreated}
/>
)}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
subject
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
mode
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
human
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
task
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
created
</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
link
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{exps.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
>
no experiments yet
</td>
</tr>
) : (
exps.map((exp) => {
const baseUrl = exp.xp_market_mode === 'airline'
? 'https://phantom-airline.vercel.app'
: 'https://phantom-hotel.vercel.app';
const link = `${baseUrl}/start-task?uuid=${exp.id}`;
return (
<tr
key={exp.id}
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{exp.subject_name}
</td>
<td className="px-4 py-3">
<span className="inline-block rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
{exp.xp_market_mode || 'none'}
</span>
</td>
<td className="px-4 py-3">
{exp.xp_human_only ? (
<span className="text-xs text-green-600 dark:text-green-400">
yes
</span>
) : (
<span className="text-xs text-zinc-500">no</span>
)}
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{exp.task ? exp.task.task_name : '—'}
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{new Date(exp.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={() => {
navigator.clipboard.writeText(link);
}}
className="text-xs font-medium text-zinc-900 hover:text-zinc-600 dark:text-zinc-100 dark:hover:text-zinc-400"
>
copy link
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +0,0 @@
import { ReactNode } from 'react';
import '@/styles/airline.css';
export default function AirlineLayout({ children }: { children: ReactNode }) {
return <div data-mode="airline">{children}</div>;
}

View File

@@ -1,9 +0,0 @@
import AirlineHero from '@/components/feats/airline/AirlineHero';
export default function AirlineHome() {
return (
<main>
<AirlineHero />
</main>
);
}

View File

@@ -1,74 +0,0 @@
'use client';
import { Navigation } from '@/components/ui';
import AirlineCard from '@/components/feats/airline/AirlineCard';
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;
}
const genRandomFlights = (): Flight[] => {
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
return Array.from({ length: 12 }, (_, i) => {
const depHour = Math.floor(Math.random() * 24);
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
const basePrice = Math.floor(
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
Math.random() * 300
);
return {
id: `flt-${i}`,
departure: {
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
airport: airports[Math.floor(Math.random() * airports.length)],
},
arrival: {
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
airport: airports[Math.floor(Math.random() * airports.length)],
},
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
stops,
cabinClass: cabin,
fareRule,
refundable: Math.random() > 0.7,
basePrice,
};
});
};
export default function AirlineProducts() {
const flights = genRandomFlights();
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
<div className="space-y-4">
{flights.map((f) => (
<AirlineCard key={f.id} flight={f} />
))}
</div>
</main>
</>
);
}

View File

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

View File

@@ -1,43 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { createExperiment, getSession } from '@/lib/sessionStore';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sessionId } = body;
if (!sessionId) {
return NextResponse.json(
{ error: 'sessionId required' },
{ status: 400 }
);
}
// verify session exists
const session = getSession(sessionId);
if (!session) {
return NextResponse.json(
{ error: 'session not found' },
{ status: 404 }
);
}
// generate and create experiment
const experimentId = randomUUID();
const exp = createExperiment(sessionId, experimentId);
return NextResponse.json({
experimentId: exp.id,
sessionId,
status: exp.status,
createdAt: exp.createdAt,
});
} catch (err: any) {
console.error('experiment start error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -1,39 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { experimentId } = body;
if (!experimentId) {
return NextResponse.json(
{ error: 'experimentId required' },
{ status: 400 }
);
}
// verify experiment exists
const existing = getExperiment(experimentId);
if (!existing) {
return NextResponse.json(
{ error: 'experiment not found' },
{ status: 404 }
);
}
// stop the experiment
const exp = stopExperimentById(experimentId);
return NextResponse.json({
experimentId: exp!.id,
status: exp!.status,
});
} catch (err: any) {
console.error('experiment stop error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

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

View File

@@ -1,42 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import type { EventBase } from '@/lib/events';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const storeMode = process.env.STORE_MODE || 'hotel';
const userAgent = req.headers.get('user-agent') || undefined;
const event: EventBase = {
...body,
storeMode,
userAgent,
ts: body.ts || new Date().toISOString(),
};
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
if (!res.ok) {
throw new Error(`Backend returned ${res.status}`);
}
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
console.log('[ingest]', event);
}
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('[ingest error]', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -1,45 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
interface PricingResponse {
price: number;
currency: string;
cachedAt: string;
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const productId = searchParams.get('productId');
const sessionId = searchParams.get('sessionId');
const experimentId = searchParams.get('experimentId');
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
// log in dev
if (process.env.NODE_ENV === 'development') {
console.log('[pricing-api]', {
productId,
sessionId,
experimentId,
storeMode,
timestamp: new Date().toISOString(),
});
}
if (!productId) {
return NextResponse.json(
{ error: 'productId is required' },
{ status: 400 }
);
}
// stub: call external pricing provider (random for now)
const basePrice = 100 + Math.random() * 900; // 100-1000 range
const price = Math.round(basePrice * 100) / 100;
const response: PricingResponse = {
price,
currency: 'EUR',
cachedAt: new Date().toISOString(),
};
return NextResponse.json(response);
}

View File

@@ -1,92 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
const COOKIE_NAME = 'phantom_session_id';
const isProd = process.env.NODE_ENV === 'production';
export async function GET(req: NextRequest) {
try {
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
if (existingSession) {
const sessionData = getSession(existingSession);
return NextResponse.json({
sessionId: existingSession,
experimentId: sessionData?.experimentId,
});
}
const sessionId = randomUUID();
createSession(sessionId);
const res = NextResponse.json({ sessionId, experimentId: undefined });
res.cookies.set({
name: COOKIE_NAME,
value: sessionId,
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 30,
});
return res;
} catch (err: any) {
console.error('session error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { experimentId } = body;
if (!experimentId) {
return NextResponse.json(
{ error: 'experimentId is required' },
{ status: 400 }
);
}
let sessionId = req.cookies.get(COOKIE_NAME)?.value;
if (!sessionId) {
sessionId = randomUUID();
createSession(sessionId);
}
setExperiment(sessionId, experimentId);
const res = NextResponse.json({
sessionId,
experimentId,
success: true
});
if (!req.cookies.get(COOKIE_NAME)) {
res.cookies.set({
name: COOKIE_NAME,
value: sessionId,
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 30,
});
}
return res;
} catch (err: any) {
console.error('session update error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendInteractionEvent } from '@/lib/kafka';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
if (!sessionId || !eventType) {
return NextResponse.json(
{ error: 'sessionId and eventType required' },
{ status: 400 }
);
}
await sendInteractionEvent({
sessionId,
eventType,
targetEl,
targetUrl,
metadata,
ts: Date.now(),
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('track error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -1,19 +1,8 @@
@import "tailwindcss";
@layer base {
:root {
--background: #ffffff;
--foreground: #171717;
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #333333;
--text-secondary: #666666;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 32px;
--border-radius: 8px;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
@theme inline {
@@ -23,7 +12,6 @@
--font-mono: var(--font-geist-mono);
}
@layer base {
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
@@ -31,79 +19,8 @@
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
button {
cursor: pointer;
border: none;
outline: none;
font-family: inherit;
transition: all 0.2s ease;
}
input, select, textarea {
font-family: inherit;
font-size: 1rem;
outline: none;
}
}
@layer components {
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.card {
background: var(--bg-primary);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.btn-primary {
padding: 12px 24px;
font-weight: 600;
font-size: 1rem;
border-radius: var(--border-radius);
transition: all 0.2s ease;
background-color: #007aff;
color: #ffffff;
border: none;
cursor: pointer;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: #0051d5;
}
.section-spacing {
margin-bottom: var(--spacing-lg);
}
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,6 +0,0 @@
import { ReactNode } from 'react';
import '@/styles/hotel.css';
export default function HotelLayout({ children }: { children: ReactNode }) {
return <div data-mode="hotel">{children}</div>;
}

View File

@@ -1,9 +0,0 @@
import HotelHero from '@/components/feats/hotel/HotelHero';
export default function HotelHome() {
return (
<main>
<HotelHero />
</main>
);
}

View File

@@ -1,75 +0,0 @@
'use client';
import { Navigation } from '@/components/ui';
import HotelCard from '@/components/feats/hotel/HotelCard';
interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
}
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>
</main>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,87 +0,0 @@
'use client';
import type { EventName } from '@/lib/events';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
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 },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: flight.id,
metadata: { elementText: 'price' },
});
const handleCardClick = () => {
dispatchInteraction('view_item_page', flight.id, {
cabinClass: flight.cabinClass,
fareRule: flight.fareRule,
price: flight.basePrice,
});
};
return (
<div
className="flight-card cursor-pointer"
onClick={handleCardClick}
>
<div className="flight-timing">
<div className="flight-time">{flight.departure.time}</div>
<div className="flight-airport">{flight.departure.airport}</div>
</div>
<div className="flight-route">
<div ref={durationRef} className="flight-duration">{flight.duration}</div>
<div className="flight-stops">
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
</div>
</div>
<div className="flight-timing">
<div className="flight-time">{flight.arrival.time}</div>
<div className="flight-airport">{flight.arrival.airport}</div>
</div>
<div className="flight-pricing">
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
{flight.refundable && (
<div className="badge-value text-xs mb-2">Refundable</div>
)}
<div ref={priceRef}>
<PriceDisplay
productId={flight.id}
className="fare-price"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,156 +0,0 @@
'use client';
import { useState, FormEvent } from 'react';
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
type TripType = 'roundtrip' | 'oneway' | 'multicity';
const PlaneIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
);
const LocationIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function AirlineHero() {
const [tripType, setTripType] = useState<TripType>('roundtrip');
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [departDate, setDepartDate] = useState('');
const [returnDate, setReturnDate] = useState('');
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
const handleSearch = (e: FormEvent) => {
e.preventDefault();
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
};
const totalPax = passengers.adults + passengers.children + passengers.infants;
return (
<div className="hero-section min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-5xl px-4">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Book flights at the best prices
</h1>
<p className="text-lg">
Compare hundreds of airlines and find the perfect flight for your journey
</p>
</div>
<div className="search-form">
<form onSubmit={handleSearch}>
<div className="mb-6">
<RadioGroup
name="tripType"
value={tripType}
onChange={setTripType}
options={[
{ value: 'roundtrip', label: 'Round-trip' },
{ value: 'oneway', label: 'One-way' },
{ value: 'multicity', label: 'Multi-city' },
]}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label htmlFor="origin">From</Label>
<Input
type="text"
id="origin"
value={origin}
onChange={(e) => setOrigin(e.target.value)}
placeholder="Airport or city"
icon={<PlaneIcon />}
required
/>
</div>
<div>
<Label htmlFor="destination">To</Label>
<Input
type="text"
id="destination"
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="Airport or city"
icon={<LocationIcon />}
required
/>
</div>
<div>
<Label htmlFor="departDate">Departure</Label>
<DateInput
id="departDate"
value={departDate}
onChange={(e) => setDepartDate(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="returnDate">Return</Label>
{tripType === 'roundtrip' ? (
<DateInput
id="returnDate"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
required
/>
) : (
<DateInput id="returnDate" disabled />
)}
</div>
</div>
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
<div className="sm:col-span-1 lg:col-span-1">
<Label htmlFor="passengers">Passengers</Label>
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
<DropdownCounter
label="Adults"
sublabel="12+ years"
value={passengers.adults}
min={1}
onChange={(v) => setPassengers({ ...passengers, adults: v })}
/>
<DropdownCounter
label="Children"
sublabel="2-11 years"
value={passengers.children}
onChange={(v) => setPassengers({ ...passengers, children: v })}
/>
<DropdownCounter
label="Infants"
sublabel="Under 2"
value={passengers.infants}
onChange={(v) => setPassengers({ ...passengers, infants: v })}
/>
</Dropdown>
</div>
</div>
<div className="mt-6">
<Button type="submit" fullWidth>
Search Flights
</Button>
</div>
</form>
</div>
<div className="mt-6 text-center text-sm">
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
</div>
</div>
</div>
);
}

View File

@@ -1,98 +0,0 @@
'use client';
import type { EventName } from '@/lib/events';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
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',
pool: 'Pool',
gym: 'Gym',
parking: 'Parking',
breakfast: 'Breakfast',
spa: 'Spa',
};
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
};
export default function HotelCard({ hotel }: { hotel: Hotel }) {
const titleRef = useHoverTracking({
eventName: 'hover_over_title',
productId: hotel.id,
metadata: { elementText: hotel.name },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: hotel.id,
metadata: { elementText: 'price' },
});
const handleCardClick = () => {
dispatchInteraction('view_item_page', hotel.id, {
roomType: hotel.roomType,
price: hotel.pricePerNight,
nights: hotel.nights,
});
};
return (
<div
className="hotel-card cursor-pointer"
onClick={handleCardClick}
>
<div className="hotel-image bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-sm">Image</span>
</div>
<div className="hotel-info">
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
<div className="text-sm text-[var(--text-secondary)] mb-2">
{hotel.checkIn} - {hotel.checkOut}
</div>
<div className="hotel-features">
{hotel.amenities.map((a) => (
<AmenityIcon key={a} name={a} />
))}
</div>
{hotel.refundable && (
<div className="free-cancellation mt-2">Free cancellation</div>
)}
</div>
<div className="hotel-pricing">
<div ref={priceRef}>
<PriceDisplay
productId={hotel.id}
className="price-wrapper"
perNight
/>
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
</div>
</div>
</div>
);
}

View File

@@ -1,103 +0,0 @@
'use client';
import { useState, FormEvent } from 'react';
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
const LocationIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function HotelHero() {
const [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 });
};
return (
<div className="hero-section min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-4xl px-4">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Find your perfect stay
</h1>
<p className="text-lg">
Search hotels, 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">
<Label htmlFor="destination">Where to?</Label>
<Input
type="text"
id="destination"
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="City, hotel, or landmark"
icon={<LocationIcon />}
required
/>
</div>
<div>
<Label htmlFor="checkIn">Check-in</Label>
<DateInput
id="checkIn"
value={checkIn}
onChange={(e) => setCheckIn(e.target.value)}
required
/>
</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'}`}>
<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">
<Button type="submit" fullWidth>
Search Hotels
</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>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
type BtnVariant = 'primary' | 'secondary';
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: BtnVariant;
children: ReactNode;
fullWidth?: boolean;
}
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
const widthClass = fullWidth ? 'w-full' : '';
return (
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
{children}
</button>
);
}

View File

@@ -1,7 +0,0 @@
import { InputHTMLAttributes } from 'react';
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
export default function DateInput({ className = '', ...props }: DateInpProps) {
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
}

View File

@@ -1,83 +0,0 @@
'use client';
import { ReactNode, useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
children: ReactNode;
}
export default function Dropdown({ label, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="input-field flex justify-between items-center w-full"
>
<span>{label}</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
{children}
</div>
)}
</div>
);
}
interface CounterProps {
label: string;
sublabel?: string;
value: number;
min?: number;
max?: number;
onChange: (val: number) => void;
}
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
return (
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{label}</span>
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
disabled={value <= min}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
</button>
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
disabled={value >= max}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
+
</button>
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { InputHTMLAttributes, ReactNode } from 'react';
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode;
}
export default function Input({ icon, className = '', style, ...props }: InpProps) {
const padClass = icon ? 'pl-10' : '';
// Fallback if a custom CSS rule still overrides Tailwind
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
return (
<div className="relative">
{icon && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
>
{icon}
</div>
)}
<input
className={`input-field ${className} ${padClass}`}
style={mergedStyle}
{...props}
/>
</div>
);
}

View File

@@ -1,13 +0,0 @@
import { ReactNode, LabelHTMLAttributes } from 'react';
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
export default function Label({ children, className = '', ...props }: LblProps) {
return (
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
{children}
</label>
);
}

View File

@@ -1,48 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, metadata },
});
document.dispatchEvent(e);
};
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
const path = usePathname();
const isActive = path === href;
return (
<Link
href={href}
className={`px-4 py-2 rounded-md transition-colors ${
isActive
? 'bg-[var(--accent-primary)] text-white font-semibold'
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
}`}
>
{children}
</Link>
);
};
export default function Navigation() {
return (
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center space-x-1">
<NavLink href="/">Home</NavLink>
<NavLink href="/products">Products</NavLink>
<NavLink href="/search">Search</NavLink>
<NavLink href="/cart">Cart</NavLink>
<NavLink href="/checkout">Checkout</NavLink>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -1,136 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
interface PriceDisplayProps {
productId: string;
className?: string;
perNight?: boolean;
}
interface PricingData {
price: number;
currency: string;
cachedAt: string;
}
interface SessionData {
sessionId: string;
experimentId?: string;
}
const fetchSession = async (): Promise<SessionData> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return {
sessionId: data.sessionId || '',
experimentId: data.experimentId || '',
};
} catch (err) {
console.error('failed to fetch session:', err);
return { sessionId: '', experimentId: '' };
}
};
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('en-US', { // like an std localization
style: 'currency',
currency,
}).format(price);
};
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
const cacheTime = new Date(cachedAt).getTime();
const now = Date.now();
return now - cacheTime > thresholdMs;
};
export default function PriceDisplay({
productId,
className = '',
perNight = false,
}: PriceDisplayProps) {
const sessionRef = useRef<SessionData | null>(null);
const [data, setData] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initAndFetch = async () => {
setLoading(true);
setError(null);
try {
// fetch session if not already loaded
if (!sessionRef.current) {
sessionRef.current = await fetchSession();
}
const { sessionId, experimentId } = sessionRef.current;
if (!sessionId) {
setError('Invalid session');
setLoading(false);
return;
}
const params = new URLSearchParams({
productId,
sessionId,
experimentId: experimentId || '',
});
const res = await fetch(`/api/pricing?${params.toString()}`);
if (!res.ok) {
throw new Error(`Failed to fetch price: ${res.status}`);
}
const pricingData: PricingData = await res.json();
setData(pricingData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
initAndFetch();
}, [productId]);
if (loading) {
return (
<div className={`price-loading ${className}`}>
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
if (error || !data) {
return (
<div className={`price-error ${className}`}>
<span className="text-red-500 text-sm">Price unavailable</span>
</div>
);
}
const isStale = isCacheStale(data.cachedAt);
const formattedPrice = formatPrice(data.price, data.currency);
return (
<div className={`price-display ${className}`}>
<div className="price-amount">
{formattedPrice}
{perNight && <span className="text-xs ml-1">/night</span>}
</div>
{isStale && (
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
prices may be outdated
</span>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
'use client';
interface RadioOpt<T extends string> {
value: T;
label: string;
}
interface RadioGrpProps<T extends string> {
name: string;
options: RadioOpt<T>[];
value: T;
onChange: (val: T) => void;
}
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
return (
<div className="flex gap-4">
{options.map((opt) => (
<label key={opt.value} className="flex items-center cursor-pointer">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value as T)}
className="mr-2"
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
);
}

View File

@@ -1,7 +0,0 @@
export { default as Button } from './Button';
export { default as Label } from './Label';
export { default as Input } from './Input';
export { default as DateInput } from './DateInput';
export { default as RadioGroup } from './RadioGroup';
export { default as Dropdown, DropdownCounter } from './Dropdown';
export { default as Navigation } from './Navigation';

View File

@@ -1,63 +0,0 @@
import { useCallback, useRef } from 'react';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (
eventName: EventName,
productId?: string,
metadata?: Record<string, unknown>
) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
interface UseHoverTrackingOptions {
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
}
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
: 1500;
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const startRef = useRef<number | undefined>(undefined);
return useCallback((node: HTMLElement | null) => {
if (!node) {
if (timerRef.current) clearTimeout(timerRef.current);
return;
}
const onEnter = () => {
startRef.current = Date.now();
timerRef.current = setTimeout(() => {
const dwellTime = Date.now() - startRef.current!;
dispatchInteraction(eventName, productId, {
...metadata,
dwellTime,
});
}, threshold);
};
const onLeave = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
node.addEventListener('mouseenter', onEnter);
node.addEventListener('mouseleave', onLeave);
return () => {
node.removeEventListener('mouseenter', onEnter);
node.removeEventListener('mouseleave', onLeave);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [eventName, productId, metadata, threshold]);
};

View File

@@ -1,90 +1,117 @@
import { useEffect, useRef, useState } from 'react';
import '@/lib/experiments'
import type { EventName } from '@/lib/events';
import { useEffect, useRef } from 'react';
import '@/lib/experiments' // ensure experiments lib is loaded
const fetchSessionId = async (): Promise<string> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return data.sessionId || '';
} catch (err) {
console.error('failed to fetch session:', err);
return '';
}
const genSessionId = () => {
if (typeof window === 'undefined') return '';
let sid = sessionStorage.getItem('phantom_session_id');
if (!sid) {
sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem('phantom_session_id', sid);
// TODO: when creating new id send to exepriemtn tracking db
// match between sesion-id and experiment-id for this session
// so that we can identify all interactions aligning with a specific experiment goal.
}
return sid;
};
const track = async (ev: {
sessionId: string;
eventName: EventName;
page: string;
productId?: string;
metadata?: Record<string, unknown>;
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
}) => {
try {
const experimentId = localStorage.getItem('phantom_experiment_id');
await fetch('/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...ev,
experimentId: experimentId || undefined,
}),
});
} catch (err) {
console.error('track failed:', err);
}
try {
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
} catch (err) {
console.error('track failed:', err);
}
};
export const useInteractionTracking = () => {
const sidRef = useRef<string>('');
const [ready, setReady] = useState(false);
useEffect(() => {
// fetch session id from httpOnly cookie via API
fetchSessionId().then((sid) => {
sidRef.current = sid;
setReady(true);
});
sidRef.current = genSessionId();
const handlePageView = () => {
if (!sidRef.current) return;
const page = window.location.pathname;
const handleClick = (e: MouseEvent) => {
const tgt = e.target as HTMLElement;
track({
sessionId: sidRef.current,
eventName: 'page_view',
page,
eventType: 'click',
targetEl: tgt.tagName,
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
metadata: {
x: e.clientX,
y: e.clientY,
path: window.location.pathname,
},
});
};
const handleScroll = () => {
track({
sessionId: sidRef.current,
eventType: 'scroll',
metadata: {
scrollY: window.scrollY,
path: window.location.pathname,
},
});
};
const handlePageView = () => {
track({
sessionId: sidRef.current,
eventType: 'pageview',
metadata: {
path: window.location.pathname,
referrer: document.referrer,
},
});
};
// called for canonical events dispatched via custom events
const handleDefinedInteraction = (e: Event) => {
if (!sidRef.current) return;
const customEvent = e as CustomEvent<{
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
}>;
const page = window.location.pathname;
enum DefinedInteractions {
ADD_TO_CART = 'add_to_cart',
PURCHASE = 'purchase',
}
// called when clicking on "Add to Cart" button or "Purchase" button
const handleDefinedInteraction = (
interactionType: DefinedInteractions,
metadata?: Record<string, any>
) => {
track({
sessionId: sidRef.current,
eventName: customEvent.detail.eventName,
page,
productId: customEvent.detail.productId,
metadata: customEvent.detail.metadata,
eventType: interactionType,
metadata: {
path: window.location.pathname,
...metadata,
},
});
};
// wait for session to be ready before tracking
if (!ready) return;
handlePageView();
document.addEventListener('definedInteraction', handleDefinedInteraction);
document.addEventListener('click', handleClick);
document.addEventListener('definedInteraction', (e: Event) => {
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
// TOO NOISY: enable if needed but tbh not worth it
//window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
document.removeEventListener('definedInteraction', handleDefinedInteraction);
document.removeEventListener('click', handleClick);
document.removeEventListener('definedInteraction', (e: Event) => {
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
//window.removeEventListener('scroll', handleScroll);
};
}, [ready]);
}, []);
};

View File

@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
type SessionState = {
sessionId: string | null;
experimentId: string | null;
isLoading: boolean;
};
export const useSession = () => {
const [state, setState] = useState<SessionState>({
sessionId: null,
experimentId: null,
isLoading: true,
});
useEffect(() => {
const fetchSession = async () => {
try {
const res = await fetch('/api/session');
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const data = await res.json();
setState({
sessionId: data.sessionId || null,
experimentId: data.experimentId || null,
isLoading: false,
});
} catch (err) {
console.error('session fetch error:', err);
setState({ sessionId: null, experimentId: null, isLoading: false });
}
};
fetchSession();
}, []);
return state;
};

View File

@@ -1,30 +0,0 @@
import { z } from 'zod';
type Env = z.infer<typeof envSchema>;
const envSchema = z.object({
STORE_MODE: z.enum(['hotel', 'airline'], {
message: 'STORE_MODE must be either "hotel" or "airline"'
}),
NEXT_PUBLIC_API_BASE: z.string().url({
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
}),
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
}),
});
// parse and validate env at module load, fail fast with descriptive errors
const parseEnv = (): Env => {
const result = envSchema.safeParse({
STORE_MODE: process.env.STORE_MODE,
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
});
if (!result.success) {
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
return result.data;
};
export const config: Env = parseEnv();

View File

@@ -1,91 +0,0 @@
import { z } from 'zod';
// canonical events for tracking user interactions
export type EventName =
// navigation & discovery
| 'page_view'
| 'view_item_page'
| 'learn_more_about_item'
// cart operations
| 'add_item_to_cart'
| 'remove_item'
| 'checkout_start'
| 'purchase_complete'
// filtering & search
| 'search'
| 'filter_for_date'
| 'filter_for_amenities'
| 'filter_for_price'
| 'sort_change'
// dwell signals (Ns threshold)
| 'hover_over_title'
| 'hover_over_paragraph'
| 'hover_over_link'
| 'hover_over_button'
// session
| 'session_start';
export const eventNames: readonly EventName[] = [
'page_view',
'view_item_page',
'learn_more_about_item',
'add_item_to_cart',
'remove_item',
'checkout_start',
'purchase_complete',
'search',
'filter_for_date',
'filter_for_amenities',
'filter_for_price',
'sort_change',
'hover_over_title',
'hover_over_paragraph',
'hover_over_link',
'hover_over_button',
'session_start',
] as const;
export interface EventBase {
sessionId: string;
experimentId?: string;
storeMode: 'hotel' | 'airline';
ts: string; // ISO8601
page: string;
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
userAgent?: string;
}
// zod schema for runtime validation
export const eventBaseSchema = z.object({
sessionId: z.string().min(1),
experimentId: z.string().optional(),
storeMode: z.enum(['hotel', 'airline']),
ts: z.string().datetime(), // validates ISO8601
page: z.string().min(1),
eventName: z.enum([
'page_view',
'view_item_page',
'learn_more_about_item',
'add_item_to_cart',
'remove_item',
'checkout_start',
'purchase_complete',
'search',
'filter_for_date',
'filter_for_amenities',
'filter_for_price',
'sort_change',
'hover_over_title',
'hover_over_paragraph',
'hover_over_link',
'hover_over_button',
'session_start',
]),
productId: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
userAgent: z.string().optional(),
});
export type EventBaseValidated = z.infer<typeof eventBaseSchema>;

42
web/src/lib/kafka.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Kafka, Producer } from 'kafkajs';
let producer: Producer | null = null;
const kafka = new Kafka({
clientId: 'phantom-web',
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
});
export const getProducer = async (): Promise<Producer> => {
if (!producer) {
producer = kafka.producer();
await producer.connect();
}
return producer;
};
export const sendInteractionEvent = async (ev: {
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
ts: number;
}) => {
const p = await getProducer();
// add to the metadata
await p.send({
topic: 'user-interactions',
messages: [{
key: ev.sessionId,
value: JSON.stringify(ev),
}],
});
};
export const disconnect = async () => {
if (producer) {
await producer.disconnect();
producer = null;
}
};

View File

@@ -1,102 +0,0 @@
type SessionData = {
experimentId?: string;
startedAt: number;
status: 'active' | 'stopped';
};
type ExperimentData = {
id: string;
status: 'active' | 'stopped';
sessionIds: string[];
createdAt: number;
};
const store = new Map<string, SessionData>();
const experiments = new Map<string, ExperimentData>();
const cfg = {
key: process.env.AIRTABLE_API_KEY,
base: process.env.AIRTABLE_BASE_ID,
table: process.env.AIRTABLE_TABLE_NAME || 'Sessions',
};
// sync session to airtable if credentials present
const syncToAirtable = async (sid: string, data: SessionData) => {
if (!cfg.key || !cfg.base) return; // skip if not configured
try {
const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`;
await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${cfg.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fields: {
sessionId: sid,
experimentId: data.experimentId || '',
startedAt: new Date(data.startedAt).toISOString(),
status: data.status,
},
}),
});
} catch (err) {
console.error('airtable sync failed:', err);
}
};
export const getSession = (sid: string) => store.get(sid);
export const createSession = (sid: string) => {
const data: SessionData = { startedAt: Date.now(), status: 'active' };
store.set(sid, data);
syncToAirtable(sid, data); // async fire-and-forget
return data;
};
export const setExperiment = (sid: string, expId: string) => {
const data = store.get(sid) || createSession(sid);
data.experimentId = expId;
store.set(sid, data);
syncToAirtable(sid, data);
return data;
};
export const stopExperiment = (sid: string) => {
const data = store.get(sid);
if (data) {
data.status = 'stopped';
store.set(sid, data);
syncToAirtable(sid, data);
}
return data;
};
// experiment-level operations
export const createExperiment = (sid: string, expId: string) => {
const exp: ExperimentData = {
id: expId,
status: 'active',
sessionIds: [sid],
createdAt: Date.now(),
};
experiments.set(expId, exp);
setExperiment(sid, expId); // link session to experiment
console.log(`experiment ${expId} started with session ${sid}`);
return exp;
};
export const stopExperimentById = (expId: string) => {
const exp = experiments.get(expId);
if (exp) {
exp.status = 'stopped';
experiments.set(expId, exp);
console.log(`experiment ${expId} stopped`);
}
return exp;
};
export const getExperiment = (expId: string) => experiments.get(expId);
export const getAllExperiments = () => Array.from(experiments.values());

View File

@@ -1,37 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
const mode = process.env.STORE_MODE;
const { pathname } = req.nextUrl;
// skip rewrites for api routes, admin routes, static files, and next internals
if (
pathname.startsWith('/api') ||
pathname.startsWith('/admin') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/static') ||
pathname.startsWith('/start-task') ||
pathname.includes('.')
// TODO: add robots.txt and sitemap.xml if needed here
) {
return NextResponse.next();
}
// already prefixed with mode
if (pathname.startsWith(`/${mode}`)) {
return NextResponse.next();
}
// rewrite root and unprefixed paths to mode-specific route group
const url = req.nextUrl.clone();
url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: [
// match all paths except those starting with _next/static, _next/image, favicon.ico
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -1,321 +0,0 @@
/* Airline Platform - Sky Blue Theme */
@layer base {
[data-mode="airline"] {
--accent-primary: #007aff;
--accent-secondary: #4caf50;
--accent-warning: #ff3b30;
--accent-primary-hover: #0051d5;
--accent-primary-light: #e6f2ff;
--text-accent: #007aff;
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
}
}
@layer components {
[data-mode="airline"] {
--primary-color: var(--accent-primary);
}
[data-mode="airline"] .btn-primary {
background-color: var(--accent-primary) !important;
color: #ffffff !important;
padding: 12px 24px;
font-weight: 600;
font-size: 1rem;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="airline"] .btn-primary:hover {
background-color: var(--accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
[data-mode="airline"] .btn-secondary {
background-color: transparent;
color: var(--accent-primary);
border: 2px solid var(--accent-primary);
}
[data-mode="airline"] .btn-secondary:hover {
background-color: var(--accent-primary-light);
}
[data-mode="airline"] .badge-value {
background-color: var(--accent-secondary);
color: #ffffff;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
[data-mode="airline"] .badge-warning {
background-color: var(--accent-warning);
color: #ffffff;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
[data-mode="airline"] .search-form {
background: var(--bg-primary);
padding: var(--spacing-lg);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
}
[data-mode="airline"] .flight-card {
display: grid;
grid-template-columns: 2fr 3fr 2fr;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--bg-primary);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
margin-bottom: var(--spacing-md);
transition: box-shadow 0.2s ease;
}
[data-mode="airline"] .flight-card:hover {
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15);
}
[data-mode="airline"] .flight-timing {
display: flex;
flex-direction: column;
justify-content: center;
}
[data-mode="airline"] .flight-time {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
[data-mode="airline"] .flight-airport {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
[data-mode="airline"] .flight-route {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
[data-mode="airline"] .flight-duration {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
[data-mode="airline"] .flight-stops {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
}
[data-mode="airline"] .flight-pricing {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing-sm);
}
[data-mode="airline"] .fare-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm);
border: 1px solid #e0e0e0;
border-radius: 6px;
transition: all 0.2s ease;
}
[data-mode="airline"] .fare-option:hover {
border-color: var(--accent-primary);
background-color: var(--accent-primary-light);
}
[data-mode="airline"] .fare-class {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
[data-mode="airline"] .fare-price {
font-size: 1.125rem;
font-weight: 700;
color: var(--accent-primary);
}
[data-mode="airline"] .date-price-bar {
display: flex;
overflow-x: auto;
gap: var(--spacing-sm);
padding: var(--spacing-md) 0;
margin-bottom: var(--spacing-lg);
}
[data-mode="airline"] .date-option {
min-width: 100px;
padding: var(--spacing-sm);
text-align: center;
border: 2px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="airline"] .date-option:hover {
border-color: var(--accent-primary);
}
[data-mode="airline"] .date-option.active {
border-color: var(--accent-primary);
background-color: var(--accent-primary-light);
}
[data-mode="airline"] .date-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
[data-mode="airline"] .date-price {
font-size: 1rem;
font-weight: 700;
color: var(--accent-primary);
}
[data-mode="airline"] .progress-wizard {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 800px;
margin: var(--spacing-lg) auto;
padding: 0 var(--spacing-md);
}
[data-mode="airline"] .wizard-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
[data-mode="airline"] .wizard-step::after {
content: '';
position: absolute;
top: 20px;
left: 50%;
width: 100%;
height: 2px;
background: #e0e0e0;
z-index: -1;
}
[data-mode="airline"] .wizard-step:last-child::after {
display: none;
}
[data-mode="airline"] .wizard-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e0e0e0;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
[data-mode="airline"] .wizard-step.active .wizard-number {
background: var(--accent-primary);
color: #ffffff;
}
[data-mode="airline"] .wizard-step.completed .wizard-number {
background: var(--accent-secondary);
color: #ffffff;
}
[data-mode="airline"] .wizard-label {
font-size: 0.875rem;
color: var(--text-secondary);
text-align: center;
}
[data-mode="airline"] .wizard-step.active .wizard-label {
color: var(--accent-primary);
font-weight: 600;
}
[data-mode="airline"] a {
color: var(--accent-primary);
text-decoration: none;
}
[data-mode="airline"] a:hover {
text-decoration: underline;
}
[data-mode="airline"] .input-field {
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
transition: border-color 0.2s ease;
width: 100%;
}
[data-mode="airline"] .input-field:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-light);
}
[data-mode="airline"] .filter-sidebar {
background: var(--bg-primary);
padding: var(--spacing-md);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
}
[data-mode="airline"] .filter-section {
margin-bottom: var(--spacing-lg);
}
[data-mode="airline"] .filter-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
[data-mode="airline"] .checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
cursor: pointer;
}
[data-mode="airline"] .checkbox-label:hover {
color: var(--accent-primary);
}
[data-mode="airline"] .hero-section {
background: var(--hero-bg);
}
}

View File

@@ -1,418 +0,0 @@
/* Hotel Platform - Action Blue Theme */
@layer base {
[data-mode="hotel"] {
--accent-primary: #007aff;
--accent-secondary: #4caf50;
--accent-warning: #d9534f;
--accent-primary-hover: #0051d5;
--accent-primary-light: #e6f2ff;
--text-accent: #007aff;
--bg-tertiary: #f5f5f7;
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
}
}
@layer components {
[data-mode="hotel"] {
--primary-color: var(--accent-primary);
}
[data-mode="hotel"] .btn-primary {
background-color: var(--accent-primary);
color: #ffffff;
padding: 12px 24px;
font-weight: 600;
font-size: 1rem;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="hotel"] .btn-primary:hover {
background-color: var(--accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
[data-mode="hotel"] .btn-secondary {
background-color: transparent;
color: var(--accent-primary);
border: 2px solid var(--accent-primary);
}
[data-mode="hotel"] .btn-secondary:hover {
background-color: var(--accent-primary-light);
}
[data-mode="hotel"] .badge-value {
background-color: var(--accent-secondary);
color: #ffffff;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
[data-mode="hotel"] .badge-warning {
background-color: var(--accent-warning);
color: #ffffff;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
[data-mode="hotel"] .badge-rating {
background-color: var(--accent-primary);
color: #ffffff;
padding: 6px 10px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 700;
}
[data-mode="hotel"] .search-form {
background: var(--bg-primary);
padding: var(--spacing-lg);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
max-width: 900px;
margin: 0 auto;
}
[data-mode="hotel"] .hotel-card {
display: grid;
grid-template-columns: 300px 1fr auto;
gap: var(--spacing-md);
background: var(--bg-primary);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
margin-bottom: var(--spacing-md);
overflow: hidden;
transition: box-shadow 0.2s ease;
}
[data-mode="hotel"] .hotel-card:hover {
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15);
}
[data-mode="hotel"] .hotel-image {
position: relative;
width: 100%;
height: 100%;
min-height: 220px;
overflow: hidden;
}
[data-mode="hotel"] .hotel-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
[data-mode="hotel"] .image-carousel {
position: relative;
}
[data-mode="hotel"] .carousel-nav {
position: absolute;
bottom: var(--spacing-sm);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
[data-mode="hotel"] .carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
[data-mode="hotel"] .carousel-dot.active {
background: #ffffff;
}
[data-mode="hotel"] .hotel-info {
padding: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
[data-mode="hotel"] .hotel-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
[data-mode="hotel"] .hotel-location {
font-size: 0.875rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
[data-mode="hotel"] .hotel-rating {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
[data-mode="hotel"] .rating-text {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
[data-mode="hotel"] .hotel-features {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
[data-mode="hotel"] .feature-tag {
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.75rem;
border-radius: 4px;
}
[data-mode="hotel"] .hotel-pricing {
padding: var(--spacing-md);
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
min-width: 200px;
}
[data-mode="hotel"] .price-wrapper {
text-align: right;
}
[data-mode="hotel"] .price-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
[data-mode="hotel"] .price-amount {
font-size: 1.75rem;
font-weight: 700;
color: var(--accent-primary);
}
[data-mode="hotel"] .price-unit {
font-size: 0.875rem;
color: var(--text-secondary);
}
[data-mode="hotel"] .price-original {
text-decoration: line-through;
color: var(--text-secondary);
font-size: 1rem;
margin-right: var(--spacing-sm);
}
[data-mode="hotel"] .urgency-message {
font-size: 0.75rem;
color: var(--accent-warning);
font-weight: 600;
margin-top: 4px;
}
[data-mode="hotel"] .free-cancellation {
font-size: 0.75rem;
color: var(--accent-secondary);
font-weight: 600;
margin-top: 4px;
}
[data-mode="hotel"] .filter-sidebar {
background: var(--bg-primary);
padding: var(--spacing-md);
border-radius: var(--border-radius);
box-shadow: var(--shadow-card);
}
[data-mode="hotel"] .filter-section {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid #e0e0e0;
}
[data-mode="hotel"] .filter-section:last-child {
border-bottom: none;
}
[data-mode="hotel"] .filter-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
[data-mode="hotel"] .checkbox-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) 0;
cursor: pointer;
}
[data-mode="hotel"] .checkbox-label:hover {
color: var(--accent-primary);
}
[data-mode="hotel"] .checkbox-count {
font-size: 0.875rem;
color: var(--text-secondary);
}
[data-mode="hotel"] .price-slider {
margin-top: var(--spacing-md);
}
[data-mode="hotel"] .slider-track {
width: 100%;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
position: relative;
}
[data-mode="hotel"] .slider-range {
height: 100%;
background: var(--accent-primary);
border-radius: 3px;
}
[data-mode="hotel"] .slider-values {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-sm);
font-size: 0.875rem;
color: var(--text-secondary);
}
[data-mode="hotel"] .map-toggle {
background: var(--bg-primary);
border: 2px solid var(--accent-primary);
color: var(--accent-primary);
padding: 12px 24px;
border-radius: var(--border-radius);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="hotel"] .map-toggle:hover {
background: var(--accent-primary);
color: #ffffff;
}
[data-mode="hotel"] .results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding: var(--spacing-md);
background: var(--bg-primary);
border-radius: var(--border-radius);
}
[data-mode="hotel"] .sort-dropdown {
padding: 8px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
background: var(--bg-primary);
cursor: pointer;
font-size: 0.875rem;
}
[data-mode="hotel"] .sort-dropdown:focus {
border-color: var(--accent-primary);
}
[data-mode="hotel"] .view-toggle {
display: flex;
gap: var(--spacing-sm);
}
[data-mode="hotel"] .view-button {
padding: 8px 12px;
background: transparent;
border: 2px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="hotel"] .view-button.active {
background: var(--accent-primary);
color: #ffffff;
border-color: var(--accent-primary);
}
[data-mode="hotel"] a {
color: var(--accent-primary);
text-decoration: none;
}
[data-mode="hotel"] a:hover {
text-decoration: underline;
}
[data-mode="hotel"] .input-field {
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
width: 100%;
transition: border-color 0.2s ease;
}
[data-mode="hotel"] .input-field:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-light);
}
[data-mode="hotel"] .tab-navigation {
display: flex;
gap: 0;
margin-bottom: var(--spacing-lg);
border-bottom: 2px solid #e0e0e0;
}
[data-mode="hotel"] .tab-item {
padding: 12px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="hotel"] .tab-item:hover {
color: var(--accent-primary);
}
[data-mode="hotel"] .tab-item.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
[data-mode="hotel"] .hero-section {
background: var(--hero-bg);
}
}

View File

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

View File

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

View File

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