mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
20 Commits
claude/set
...
first-pric
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a57bc10b | |||
| 5b87fde8ed | |||
| 07262e5c8f | |||
| 633edcd76b | |||
| c69fb108f2 | |||
| c639d99be2 | |||
|
|
8b76d24ade | ||
|
|
894ce87a5d | ||
|
|
ab8b8787a8 | ||
| 9bb6f842f4 | |||
| 53a39b07dd | |||
| 4acfb019f8 | |||
|
|
37b2099ee0 | ||
|
|
7ece6e82cb | ||
| 6b7060450c | |||
|
|
f6e780fdf1 | ||
|
|
f427943ebc | ||
| aa98b2d169 | |||
| 5777437540 | |||
| e677170fd2 |
21
.env.example
21
.env.example
@@ -1,5 +1,18 @@
|
||||
HOSTNAME=localhost
|
||||
# Network configuration
|
||||
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||
|
||||
# PORTS
|
||||
KAFKA_PORT=9092
|
||||
REDIS_PORT=6377
|
||||
# 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
|
||||
|
||||
15
.github/workflows/latex.yml
vendored
15
.github/workflows/latex.yml
vendored
@@ -26,18 +26,3 @@ jobs:
|
||||
with:
|
||||
name: thesis-pdf
|
||||
path: paper/build/main.pdf
|
||||
|
||||
- name: Deploy PDF to GitHub Pages
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Copy PDF to docs directory for GitHub Pages
|
||||
mkdir -p docs/static/pdfs
|
||||
cp paper/build/main.pdf docs/static/pdfs/thesis.pdf
|
||||
|
||||
# Configure git
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
|
||||
# Commit and push if there are changes
|
||||
git add docs/static/pdfs/thesis.pdf
|
||||
git diff --quiet && git diff --staged --quiet || (git commit -m "Update thesis PDF [skip ci]" && git push)
|
||||
|
||||
30
.github/workflows/pytest.yml
vendored
Normal file
30
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: .venv/bin/pytest -v
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,8 @@
|
||||
**/.env
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/.ipynb_checkpoints/
|
||||
**/.virtual_documents/
|
||||
**/session_*.svg
|
||||
**/*graph.svg
|
||||
paper/src/bib/auto
|
||||
|
||||
15
Makefile
15
Makefile
@@ -4,6 +4,10 @@ BUILDDIR := build
|
||||
TEX := main.tex
|
||||
JOBNAME := main
|
||||
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
PYTEST := $(VENV)/bin/pytest
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -35,5 +39,14 @@ clean:
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
rm -rf paper/$(BUILDDIR)/*
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
.PHONY: all pdf clean watch run.webapp
|
||||
install: $(VENV)
|
||||
$(PIP) install -r requirements.txt
|
||||
|
||||
test: $(VENV)
|
||||
$(PYTEST) -v
|
||||
|
||||
.PHONY: all pdf clean watch run.webapp install test
|
||||
|
||||
190
README.md
190
README.md
@@ -1,191 +1,5 @@
|
||||
# PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms
|
||||
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
|
||||
> Bachelor's Thesis Project by Daniel Rösel, IE University Madrid (2025)
|
||||
> Advisor: Alberto Martín Izquierdo
|
||||
- https://phantom-hotel.vercel.app/
|
||||
- https://phantom-airline.vercel.app/
|
||||
|
||||
## Overview
|
||||
|
||||
PHANTOM is an academic research project investigating **pricing heuristics to protect e-commerce platforms from exploitation by LLM agents** in dynamic pricing environments. This project explores behavioral signature detection for agent identification and develops protection mechanisms against automated transaction orchestration.
|
||||
|
||||
**Research Focus Areas:**
|
||||
- AI security in e-commerce systems
|
||||
- Behavioral signature detection for autonomous agent identification
|
||||
- Dynamic pricing protection mechanisms
|
||||
- LLM agent behavior analysis and exploitation patterns
|
||||
|
||||
## Project Structure
|
||||
|
||||
This repository contains both the research thesis and a full-stack experimental platform:
|
||||
|
||||
```
|
||||
PHANTOM/
|
||||
├── paper/ # LaTeX Bachelor's Thesis
|
||||
│ ├── src/ # LaTeX source files
|
||||
│ ├── build/ # Compiled PDF output
|
||||
│ └── concat_code.sh # Auto-concatenate code for appendix
|
||||
│
|
||||
├── docs/ # GitHub Pages academic project page
|
||||
│ └── index.html # Academic project showcase
|
||||
│
|
||||
├── web/ # Next.js research platform dashboard
|
||||
│ └── src/ # Frontend application
|
||||
│
|
||||
├── backend/ # Python backend services
|
||||
│ ├── provider/ # Data provider service
|
||||
│ └── worker/ # Kafka consumer worker
|
||||
│
|
||||
├── experiments/ # Research experiments and data analysis
|
||||
│ └── data_export.ipynb
|
||||
│
|
||||
└── docker/ # Infrastructure configurations
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose (for infrastructure)
|
||||
- Node.js 18+ (for web application)
|
||||
- Python 3.9+ (for backend services)
|
||||
- LaTeX distribution (for building thesis paper)
|
||||
|
||||
### Infrastructure Setup
|
||||
|
||||
Start Kafka, Redis, and monitoring services:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Services:**
|
||||
- Redis: `localhost:6379`
|
||||
- Kafka: `localhost:9092`
|
||||
- Zookeeper: `localhost:2181`
|
||||
- Redpanda Console (Kafka UI): `http://localhost:8080`
|
||||
|
||||
### Web Application
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Access at `http://localhost:3000`
|
||||
|
||||
### Backend Services
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Run worker:
|
||||
```bash
|
||||
cd backend/worker
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Building the Thesis Paper
|
||||
|
||||
### Using Make
|
||||
|
||||
```bash
|
||||
make pdf # Compile LaTeX to PDF
|
||||
make watch # Continuous compilation (live preview)
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
### Manual Build
|
||||
|
||||
```bash
|
||||
cd paper/src
|
||||
latexmk -pdf main.tex
|
||||
```
|
||||
|
||||
**Output**: `paper/build/main.pdf`
|
||||
|
||||
### Automated CI/CD
|
||||
|
||||
The thesis PDF is automatically built via GitHub Actions on every push to `main` that affects `paper/**`. The compiled PDF artifact is available in the Actions tab.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
**Frontend**: Next.js 14, React 18, TypeScript, Tailwind CSS
|
||||
**Backend**: Python, Kafka (event streaming)
|
||||
**Infrastructure**: Redis (cache), Kafka + Zookeeper
|
||||
**Monitoring**: Redpanda Console
|
||||
**Data Analysis**: Jupyter Notebooks, Pandas, Matplotlib
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
The platform uses Kafka for real-time event streaming, enabling:
|
||||
- Asynchronous task processing
|
||||
- Scalable data collection
|
||||
- Experiment tracking and analysis
|
||||
- Behavioral pattern detection
|
||||
|
||||
## Research Experiments
|
||||
|
||||
Jupyter notebooks in `experiments/` contain:
|
||||
- Data exploration and analysis
|
||||
- Behavioral pattern visualizations
|
||||
- Statistical analysis of agent behaviors
|
||||
- Experiment result processing
|
||||
|
||||
Run experiments:
|
||||
```bash
|
||||
cd experiments
|
||||
jupyter notebook data_export.ipynb
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Academic Project Page**: Hosted on GitHub Pages at `/docs`
|
||||
- **Thesis Paper**: Latest PDF available via GitHub Actions artifacts
|
||||
- **Web App README**: See `web/README.md`
|
||||
- **Backend READMEs**: See `backend/provider/README.md` and `backend/worker/README.md`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Paper Development**: Edit LaTeX files in `paper/src/`, use `make watch` for live preview
|
||||
2. **Web Development**: Standard Next.js workflow in `web/`
|
||||
3. **Backend Development**: Python services in `backend/`
|
||||
4. **Experiments**: Jupyter notebooks in `experiments/`
|
||||
|
||||
### Code in Thesis Appendix
|
||||
|
||||
The `paper/concat_code.sh` script automatically generates a LaTeX appendix containing all source code from:
|
||||
- `backend/` (Python, JavaScript, Shell, YAML)
|
||||
- `experiments/` (Analysis scripts)
|
||||
- `docker/` (Infrastructure configs)
|
||||
- `web/src/` (TypeScript/React components)
|
||||
|
||||
This runs automatically during PDF compilation.
|
||||
|
||||
## Contributing
|
||||
|
||||
This is an academic thesis project. For questions or collaboration inquiries, please open an issue.
|
||||
|
||||
## License
|
||||
|
||||
Academic research project - all rights reserved.
|
||||
|
||||
## Citation
|
||||
|
||||
```bibtex
|
||||
@thesis{rosel2025phantom,
|
||||
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
|
||||
author={Rösel, Daniel},
|
||||
year={2025},
|
||||
school={IE University},
|
||||
address={Madrid, Spain},
|
||||
type={Bachelor's Thesis}
|
||||
}
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Special thanks to Alberto Martín Izquierdo for academic supervision and guidance throughout this research project.
|
||||
|
||||
362
backend/server/app.py
Normal file
362
backend/server/app.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# boilerplate code
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
import uvicorn
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
||||
from kafka.admin import NewTopic
|
||||
from kafka.errors import TopicAlreadyExistsError
|
||||
from dotenv import load_dotenv
|
||||
from supabase import create_client, Client
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# kafka producer - lazy init
|
||||
_producer: Optional[KafkaProducer] = None
|
||||
|
||||
# supabase client - lazy init
|
||||
_supabase: Optional[Client] = None
|
||||
|
||||
def get_supabase() -> Client:
|
||||
global _supabase
|
||||
if _supabase is None:
|
||||
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
||||
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||
if not url or not key:
|
||||
raise ValueError("Supabase credentials not configured")
|
||||
_supabase = create_client(url, key)
|
||||
return _supabase
|
||||
|
||||
def get_producer() -> KafkaProducer:
|
||||
global _producer
|
||||
if _producer is None:
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}' if port else host
|
||||
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
||||
_producer = KafkaProducer(
|
||||
bootstrap_servers=[broker],
|
||||
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
||||
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
||||
acks=1,
|
||||
retries=3,
|
||||
max_in_flight_requests_per_connection=5,
|
||||
request_timeout_ms=30000,
|
||||
api_version_auto_timeout_ms=10000,
|
||||
max_block_ms=5000, # don't block send() for more than 5s
|
||||
)
|
||||
print(f"[KAFKA_INIT] Producer created successfully")
|
||||
return _producer
|
||||
|
||||
class EventPayload(BaseModel):
|
||||
sessionId: str
|
||||
experimentId: Optional[str] = None
|
||||
eventName: str
|
||||
page: str
|
||||
productId: Optional[str] = None
|
||||
metadata: Optional[dict[str, Any]] = None
|
||||
storeMode: str
|
||||
userAgent: Optional[str] = None
|
||||
ts: Optional[str] = None
|
||||
|
||||
class PriceLogPayload(BaseModel):
|
||||
productId: str
|
||||
price: float
|
||||
sessionId: str
|
||||
experimentId: Optional[str] = None
|
||||
storeMode: str
|
||||
ts: Optional[str] = None
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""create kafka topics on startup"""
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
print(f"[STARTUP] Creating Kafka topics on {broker}")
|
||||
admin = KafkaAdminClient(
|
||||
bootstrap_servers=[broker],
|
||||
request_timeout_ms=10000,
|
||||
)
|
||||
|
||||
topics = [
|
||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||
]
|
||||
|
||||
admin.create_topics(new_topics=topics, validate_only=False)
|
||||
print(f"[STARTUP] Topics created successfully")
|
||||
admin.close()
|
||||
except TopicAlreadyExistsError:
|
||||
print(f"[STARTUP] Topics already exist, skipping creation")
|
||||
except Exception as e:
|
||||
print(f"[STARTUP] Failed to create topics: {e}")
|
||||
print(f"[STARTUP] Will rely on auto-creation on first message")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
kafka_status = "unknown"
|
||||
try:
|
||||
producer = get_producer()
|
||||
# attempt to get cluster metadata to verify connection
|
||||
producer.bootstrap_connected()
|
||||
kafka_status = "connected"
|
||||
except Exception as e:
|
||||
kafka_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"kafka": kafka_status,
|
||||
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/kafka/ingest")
|
||||
async def ingest_logs(event: EventPayload):
|
||||
try:
|
||||
if not event.ts:
|
||||
event.ts = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
producer = get_producer()
|
||||
future = producer.send(
|
||||
'user-interactions',
|
||||
key=event.sessionId,
|
||||
value=event.model_dump()
|
||||
)
|
||||
# add callback for error logging but don't block
|
||||
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/kafka/price-log")
|
||||
async def ingest_price_log(price_log: PriceLogPayload):
|
||||
try:
|
||||
if not price_log.ts:
|
||||
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
producer = get_producer()
|
||||
future = producer.send(
|
||||
'price-logs',
|
||||
key=price_log.productId,
|
||||
value=price_log.model_dump()
|
||||
)
|
||||
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRICE_LOG_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/kafka/dump")
|
||||
def dump_logs(
|
||||
topic: str = 'user-interactions',
|
||||
last_n: Optional[int] = None,
|
||||
t_start: Optional[str] = None,
|
||||
t_end: Optional[str] = None
|
||||
):
|
||||
"""dump all messages from specified kafka topic
|
||||
|
||||
params:
|
||||
topic: kafka topic to dump (default: user-interactions)
|
||||
last_n: return only last n messages (default: all)
|
||||
t_start: filter by start timestamp iso format
|
||||
t_end: filter by end timestamp iso format
|
||||
"""
|
||||
if topic not in ['user-interactions', 'price-logs']:
|
||||
raise HTTPException(status_code=400, detail="Invalid topic")
|
||||
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
consumer = KafkaConsumer(
|
||||
topic,
|
||||
bootstrap_servers=[broker],
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=5000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
|
||||
consumer.close()
|
||||
|
||||
# apply filters
|
||||
if t_start or t_end:
|
||||
filtered = []
|
||||
for e in events:
|
||||
ts = e.get('ts')
|
||||
if ts:
|
||||
if t_start and ts < t_start:
|
||||
continue
|
||||
if t_end and ts > t_end:
|
||||
continue
|
||||
filtered.append(e)
|
||||
events = filtered
|
||||
|
||||
if last_n and last_n > 0:
|
||||
events = events[-last_n:]
|
||||
|
||||
return {"success": True, "count": len(events), "data": events}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[DUMP_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/products/{product_id}")
|
||||
async def get_product_by_id(product_id: str):
|
||||
"""fetch single product by id from either hotel_products or airline_products"""
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
|
||||
# try hotel_products first
|
||||
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
|
||||
if response.data and len(response.data) > 0:
|
||||
return {"success": True, "data": response.data[0]}
|
||||
|
||||
# try airline_products
|
||||
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
|
||||
if response.data and len(response.data) > 0:
|
||||
return {"success": True, "data": response.data[0]}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRODUCT_BY_ID_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/products/type/{product_type}")
|
||||
async def get_products(
|
||||
product_type: str,
|
||||
dateIndex: Optional[int] = None,
|
||||
origin: Optional[str] = None,
|
||||
destination: Optional[str] = None,
|
||||
tripType: Optional[str] = None,
|
||||
adults: Optional[int] = None,
|
||||
children: Optional[int] = None,
|
||||
infants: Optional[int] = None,
|
||||
rooms: Optional[int] = None
|
||||
):
|
||||
"""fetch products from supabase based on type (hotel or airline)
|
||||
|
||||
params:
|
||||
product_type: either 'hotel' or 'airline'
|
||||
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
||||
origin: (airline) departure airport code
|
||||
destination: (airline/hotel) arrival airport or hotel location
|
||||
tripType: (airline) roundtrip, oneway, multicity
|
||||
adults, children, infants: passenger counts
|
||||
rooms: (hotel) number of rooms
|
||||
"""
|
||||
if product_type not in ['hotel', 'airline']:
|
||||
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
|
||||
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
table = f'{product_type}_products'
|
||||
|
||||
query = supabase.table(table).select('*')
|
||||
|
||||
# filter by exact date_index if provided
|
||||
if dateIndex is not None:
|
||||
query = query.eq('date_index', dateIndex)
|
||||
|
||||
response = query.execute()
|
||||
results = response.data
|
||||
|
||||
# apply in-memory filters based on metadata for airline products
|
||||
if product_type == 'airline' and results:
|
||||
filtered = []
|
||||
for product in results:
|
||||
metadata = product.get('metadata', {})
|
||||
|
||||
# filter by origin airport
|
||||
if origin:
|
||||
dep = metadata.get('departure', {})
|
||||
if dep.get('airport') != origin:
|
||||
continue
|
||||
|
||||
# filter by destination airport
|
||||
if destination:
|
||||
arr = metadata.get('arrival', {})
|
||||
if arr.get('airport') != destination:
|
||||
continue
|
||||
|
||||
# passenger count validation (ensure total capacity)
|
||||
if adults is not None or children is not None or infants is not None:
|
||||
total_pax = (adults or 0) + (children or 0) + (infants or 0)
|
||||
avail = product.get('availability', 0)
|
||||
if avail < total_pax:
|
||||
continue
|
||||
|
||||
filtered.append(product)
|
||||
|
||||
results = filtered
|
||||
|
||||
# apply in-memory filters for hotel products
|
||||
elif product_type == 'hotel' and results:
|
||||
filtered = []
|
||||
for product in results:
|
||||
metadata = product.get('metadata', {})
|
||||
|
||||
# filter by occupancy capacity
|
||||
if adults is not None:
|
||||
max_occ = metadata.get('max_occupancy', 2)
|
||||
if max_occ < adults:
|
||||
continue
|
||||
|
||||
# filter by room availability
|
||||
if rooms is not None:
|
||||
avail = product.get('availability', 0)
|
||||
if avail < rooms:
|
||||
continue
|
||||
|
||||
filtered.append(product)
|
||||
|
||||
results = filtered
|
||||
|
||||
return {"success": True, "count": len(results), "data": results}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRODUCTS_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||
6
backend/server/requirements.txt
Normal file
6
backend/server/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
kafka-python==2.0.2
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
supabase==2.9.1
|
||||
@@ -1,15 +1,37 @@
|
||||
services:
|
||||
backend:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
environment:
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_PORT=5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
depends_on:
|
||||
- kafka
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "PHANTOM-redis"
|
||||
image: redis:7-alpine
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Redis.dockerfile
|
||||
ports:
|
||||
- "${REDIS_PORT:-6378}:6379"
|
||||
volumes:
|
||||
- phantom_redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
zookeeper:
|
||||
container_name: "PHANTOM-zookeeper"
|
||||
image: confluentinc/cp-zookeeper:latest
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Zookeeper.dockerfile
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
@@ -17,7 +39,9 @@ services:
|
||||
|
||||
kafka:
|
||||
container_name: "PHANTOM-kafka"
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Kafka.dockerfile
|
||||
depends_on:
|
||||
- zookeeper
|
||||
environment:
|
||||
@@ -36,7 +60,9 @@ services:
|
||||
|
||||
redpanda-console:
|
||||
container_name: "PHANTOM-redpanda-console"
|
||||
image: docker.redpanda.com/redpandadata/console:latest
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: RedpandaConsole.dockerfile
|
||||
depends_on:
|
||||
- kafka
|
||||
environment:
|
||||
|
||||
7
docker/Kafka.dockerfile
Normal file
7
docker/Kafka.dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
4
docker/Redis.dockerfile
Normal file
4
docker/Redis.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
# Expose Redis port
|
||||
EXPOSE 6379
|
||||
4
docker/RedpandaConsole.dockerfile
Normal file
4
docker/RedpandaConsole.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM docker.redpanda.com/redpandadata/console:latest
|
||||
|
||||
# Expose Redpanda Console web UI port
|
||||
EXPOSE 8080
|
||||
4
docker/Zookeeper.dockerfile
Normal file
4
docker/Zookeeper.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM confluentinc/cp-zookeeper:latest
|
||||
|
||||
# Expose Zookeeper client port
|
||||
EXPOSE 2181
|
||||
12
docker/backend.Dockerfile
Normal file
12
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
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"]
|
||||
@@ -47,7 +47,7 @@
|
||||
<meta name="citation_author" content="Rösel, Daniel">
|
||||
<meta name="citation_publication_date" content="2025">
|
||||
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
||||
<meta name="citation_pdf_url" content="static/pdfs/thesis.pdf">
|
||||
<meta name="citation_pdf_url" content="TODO">
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
@@ -238,14 +238,14 @@
|
||||
|
||||
<div class="column has-text-centered">
|
||||
<div class="publication-links">
|
||||
<!-- Thesis PDF - automatically updated via GitHub Actions -->
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<a href="static/pdfs/thesis.pdf" target="_blank"
|
||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-pdf"></i>
|
||||
</span>
|
||||
<span>Thesis PDF</span>
|
||||
<span>Paper</span>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -270,8 +270,8 @@
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<!-- TODO: Update with your arXiv paper ID when available -->
|
||||
<!-- <span class="link-block">
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
@@ -279,7 +279,7 @@
|
||||
</span>
|
||||
<span>arXiv</span>
|
||||
</a>
|
||||
</span> -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
experiments/__init__.py
Normal file
0
experiments/__init__.py
Normal file
1
experiments/agents/__init__.py
Normal file
1
experiments/agents/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Agentic behavior runner for PHANTOM research platform."""
|
||||
47
experiments/agents/agent.py
Normal file
47
experiments/agents/agent.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from .base import Agent as BaseAgent
|
||||
from browser_use import Browser, Agent, ChatOpenAI
|
||||
from enum import Enum
|
||||
|
||||
class AgentTypes(str, Enum):
|
||||
GENERIC_BROWSER_USE_AGENT = "generic_browser_use_agent"
|
||||
|
||||
def _build_prompt(goal : str, environment_url : str) -> str:
|
||||
#TODO: Improve prompt engineering here and experiment with
|
||||
return f"""You are an autonomous agent tasked with achieving the following goal: {goal}
|
||||
You have access to a web browser to interact with the environment at {environment_url}.
|
||||
Use the browser to navigate, gather information, and perform actions necessary to accomplish your goal.
|
||||
Be thorough and ensure you complete the task fully."""
|
||||
|
||||
class GenericBrowserUseAgent(BaseAgent):
|
||||
def __init__(self,
|
||||
goal: str,
|
||||
url: str = "http://localhost:3000",
|
||||
timeout: int = 300,
|
||||
llm_model: str = "gpt-5-mini",
|
||||
headless: bool = True):
|
||||
super().__init__(goal, url, timeout)
|
||||
self.llm_model = ChatOpenAI(model=llm_model)
|
||||
self.browser = Browser(headless=headless)
|
||||
self.agent = Agent(task=_build_prompt(goal, url),
|
||||
llm=self.llm_model,
|
||||
browser=self.browser)
|
||||
async def act(self) -> str:
|
||||
self.result = await self.agent.run()
|
||||
# https://github.com/browser-use/browser-use/blob/main/browser_use/agent/views.py#L301
|
||||
return self.result.final_result()
|
||||
|
||||
def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
||||
if agent_type == AgentTypes.GENERIC_BROWSER_USE_AGENT:
|
||||
return GenericBrowserUseAgent(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown agent type: {agent_type}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
JTBD= "Find me the cheapest room in Madrid for 2 people in the next two days, review each hotel room in detail and then add it to cart."
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||
goal=JTBD,
|
||||
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
||||
timeout=300)
|
||||
R=asyncio.run(agent.act())
|
||||
print(R)
|
||||
19
experiments/agents/base.py
Normal file
19
experiments/agents/base.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
class Agent(ABC):
|
||||
"""Base interface for browser automation agents"""
|
||||
|
||||
def __init__(self, goal: str, url: str = "http://localhost:3000", timeout: int = 300):
|
||||
self.goal = goal
|
||||
self.url = url
|
||||
self.timeout = timeout
|
||||
self.result: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
async def act(self) -> str:
|
||||
"""Execute goal and return result text"""
|
||||
pass
|
||||
|
||||
def final_result(self) -> Optional[str]:
|
||||
return self.result
|
||||
30
experiments/agents/test.py
Normal file
30
experiments/agents/test.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from experiments.agents.agent import get_agent, AgentTypes
|
||||
import os
|
||||
|
||||
|
||||
def test_agent_init():
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="test", url="http://example.com", timeout=100)
|
||||
assert agent.goal == "test"
|
||||
assert agent.url == "http://example.com"
|
||||
assert agent.timeout == 100
|
||||
|
||||
|
||||
def test_invalid_agent():
|
||||
with pytest.raises(ValueError):
|
||||
get_agent("invalid", goal="test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
|
||||
async def test_agent_execution():
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="get page title", url="https://example.com", timeout=60)
|
||||
|
||||
result = await agent.act()
|
||||
assert result
|
||||
assert agent.final_result()
|
||||
assert agent.final_result().history[-1].result[-1].is_done == True
|
||||
assert isinstance(result, str)
|
||||
assert "example" in result.lower()
|
||||
assert len(result) > 0
|
||||
@@ -1,721 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 98,
|
||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 98,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from kafka import KafkaConsumer\n",
|
||||
"import pandas as pd\n",
|
||||
"import json\n",
|
||||
"import numpy as np\n",
|
||||
"import os\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from IPython.display import display, SVG, Image\n",
|
||||
"load_dotenv()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 86,
|
||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||
"RangeIndex: 141 entries, 0 to 140\n",
|
||||
"Data columns (total 10 columns):\n",
|
||||
" # Column Non-Null Count Dtype \n",
|
||||
"--- ------ -------------- ----- \n",
|
||||
" 0 sessionId 141 non-null object \n",
|
||||
" 1 eventType 141 non-null object \n",
|
||||
" 2 ts 141 non-null int64 \n",
|
||||
" 3 targetEl 14 non-null object \n",
|
||||
" 4 targetUrl 1 non-null object \n",
|
||||
" 5 metadata_path 141 non-null object \n",
|
||||
" 6 metadata_referrer 6 non-null object \n",
|
||||
" 7 metadata_x 14 non-null float64\n",
|
||||
" 8 metadata_y 14 non-null float64\n",
|
||||
" 9 metadata_scrollY 121 non-null float64\n",
|
||||
"dtypes: float64(3), int64(1), object(6)\n",
|
||||
"memory usage: 11.1+ KB\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
||||
"topic = \"user-interactions\"\n",
|
||||
"consumer = KafkaConsumer(\n",
|
||||
" topic, \n",
|
||||
" enable_auto_commit=True,\n",
|
||||
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
||||
" auto_offset_reset='earliest',\n",
|
||||
" bootstrap_servers=['localhost:9092'])\n",
|
||||
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
||||
"df = []\n",
|
||||
"for m in messages.values():\n",
|
||||
" for i in m:\n",
|
||||
" df.append(i.value)\n",
|
||||
"df = pd.DataFrame(df)\n",
|
||||
"# explode metadata col json\n",
|
||||
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
||||
"df.info()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 87,
|
||||
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>sessionId</th>\n",
|
||||
" <th>eventType</th>\n",
|
||||
" <th>ts</th>\n",
|
||||
" <th>targetEl</th>\n",
|
||||
" <th>targetUrl</th>\n",
|
||||
" <th>metadata_path</th>\n",
|
||||
" <th>metadata_referrer</th>\n",
|
||||
" <th>metadata_x</th>\n",
|
||||
" <th>metadata_y</th>\n",
|
||||
" <th>metadata_scrollY</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761226211163</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226218090</td>\n",
|
||||
" <td>MAIN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>815.0</td>\n",
|
||||
" <td>331.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226220890</td>\n",
|
||||
" <td>MAIN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>1129.0</td>\n",
|
||||
" <td>605.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226225801</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>532.0</td>\n",
|
||||
" <td>545.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>4</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226229364</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>481.0</td>\n",
|
||||
" <td>399.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>5</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761227236426</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>6</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761227239328</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>202.0</td>\n",
|
||||
" <td>351.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>7</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761227244783</td>\n",
|
||||
" <td>A</td>\n",
|
||||
" <td>https://vercel.com/new?utm_source=create-next-...</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>377.0</td>\n",
|
||||
" <td>723.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>8</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761828261783</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>9</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761828266484</td>\n",
|
||||
" <td>H1</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>527.0</td>\n",
|
||||
" <td>169.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>10</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270314</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>51.666668</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>11</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270328</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>50.000000</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>12</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270336</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>49.166668</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" sessionId eventType ts targetEl \\\n",
|
||||
"0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n",
|
||||
"1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n",
|
||||
"2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n",
|
||||
"3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n",
|
||||
"4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n",
|
||||
"5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n",
|
||||
"6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n",
|
||||
"7 1761227236286-e7mphcvw6t click 1761227244783 A \n",
|
||||
"8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n",
|
||||
"9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n",
|
||||
"10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n",
|
||||
"11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n",
|
||||
"12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n",
|
||||
"\n",
|
||||
" targetUrl metadata_path \\\n",
|
||||
"0 NaN / \n",
|
||||
"1 NaN / \n",
|
||||
"2 NaN / \n",
|
||||
"3 NaN / \n",
|
||||
"4 NaN / \n",
|
||||
"5 NaN / \n",
|
||||
"6 NaN / \n",
|
||||
"7 https://vercel.com/new?utm_source=create-next-... / \n",
|
||||
"8 NaN / \n",
|
||||
"9 NaN / \n",
|
||||
"10 NaN / \n",
|
||||
"11 NaN / \n",
|
||||
"12 NaN / \n",
|
||||
"\n",
|
||||
" metadata_referrer metadata_x metadata_y metadata_scrollY \n",
|
||||
"0 NaN NaN NaN \n",
|
||||
"1 NaN 815.0 331.0 NaN \n",
|
||||
"2 NaN 1129.0 605.0 NaN \n",
|
||||
"3 NaN 532.0 545.0 NaN \n",
|
||||
"4 NaN 481.0 399.0 NaN \n",
|
||||
"5 NaN NaN NaN \n",
|
||||
"6 NaN 202.0 351.0 NaN \n",
|
||||
"7 NaN 377.0 723.0 NaN \n",
|
||||
"8 NaN NaN NaN \n",
|
||||
"9 NaN 527.0 169.0 NaN \n",
|
||||
"10 NaN NaN NaN 51.666668 \n",
|
||||
"11 NaN NaN NaN 50.000000 \n",
|
||||
"12 NaN NaN NaN 49.166668 "
|
||||
]
|
||||
},
|
||||
"execution_count": 87,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df.groupby('sessionId').head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 88,
|
||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['1761225843899-qaiwwwyj2o',\n",
|
||||
" '1761828056433-0gz7aboz86h',\n",
|
||||
" '1761227236286-e7mphcvw6t']"
|
||||
]
|
||||
},
|
||||
"execution_count": 88,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"sessions = list(set(df['sessionId'])); sessions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 89,
|
||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# map sessions to experiments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 101,
|
||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||
" df = df.dropna(subset=['eventType'])\n",
|
||||
" events = df['eventType'].tolist()\n",
|
||||
" labels = pd.Index(events).unique().tolist()\n",
|
||||
" idx = {e:i for i,e in enumerate(labels)}\n",
|
||||
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
||||
" for a, b in zip(events, events[1:]):\n",
|
||||
" M[idx[a], idx[b]] += 1\n",
|
||||
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
||||
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
||||
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
||||
" return P, labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 107,
|
||||
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
||||
"from graphviz import Digraph\n",
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def _as_prob_df(matrix, labels=None):\n",
|
||||
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
||||
" if isinstance(matrix, pd.DataFrame):\n",
|
||||
" # Ensure square and aligned\n",
|
||||
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
||||
" return matrix\n",
|
||||
" matrix = np.asarray(matrix, dtype=float)\n",
|
||||
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
||||
" if labels is None:\n",
|
||||
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
||||
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
||||
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
||||
"\n",
|
||||
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
||||
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
||||
" edges = []\n",
|
||||
" for src in P.index:\n",
|
||||
" for dst in P.columns:\n",
|
||||
" w = float(P.loc[src, dst])\n",
|
||||
" if w > threshold:\n",
|
||||
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
||||
" return edges\n",
|
||||
"\n",
|
||||
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
||||
" \"\"\"\n",
|
||||
" fname: output file stem (no extension)\n",
|
||||
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
||||
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
||||
" threshold: hide edges with weight <= threshold\n",
|
||||
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
||||
" view: open after rendering\n",
|
||||
" \"\"\"\n",
|
||||
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
||||
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
||||
"\n",
|
||||
" g = Digraph(format=fmt)\n",
|
||||
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
||||
" g.attr(\"node\", shape=\"circle\")\n",
|
||||
"\n",
|
||||
" # ensure isolated nodes appear\n",
|
||||
" for node in P.index:\n",
|
||||
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
||||
"\n",
|
||||
" for src, dst, label in edges:\n",
|
||||
" g.edge(src, dst, label=label)\n",
|
||||
"\n",
|
||||
" g.render(fname, view=view, cleanup=True)\n",
|
||||
" return g\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 108,
|
||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"228pt\" height=\"124pt\"\n",
|
||||
" viewBox=\"0.00 0.00 228.00 124.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 119.83)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd404165c70>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1.]\n",
|
||||
" [0. 1.]]\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"358pt\" height=\"132pt\"\n",
|
||||
" viewBox=\"0.00 0.00 358.00 132.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 128.41)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-128.41 354.16,-128.41 354.16,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->pageview -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->pageview</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M30.86,-87.29C31.64,-98.6 36.22,-107.16 44.58,-107.16 49.94,-107.16 53.74,-103.65 55.99,-98.15\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"59.33,-99.28 57.99,-88.77 52.48,-97.82 59.33,-99.28\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-111.11\" font-family=\"Times,serif\" font-size=\"14.00\">0.2</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.8</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->pageview -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>click->pageview</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M150.74,-29.52C143.93,-26.96 136.67,-24.68 129.66,-23.33 119.02,-21.28 107.71,-22.06 96.96,-24.24\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"96.33,-20.79 87.47,-26.6 98.02,-27.59 96.33,-20.79\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-27.28\" font-family=\"Times,serif\" font-size=\"14.00\">0.3</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.6</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- scroll -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>scroll</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"314.16\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">scroll</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->scroll -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>click->scroll</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M220.12,-44.58C234.44,-44.58 251.18,-44.58 266.47,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"266.31,-48.08 276.31,-44.58 266.31,-41.08 266.31,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"248.91\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.1</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- scroll->scroll -->\n",
|
||||
"<g id=\"edge6\" class=\"edge\">\n",
|
||||
"<title>scroll->scroll</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M301.93,-78.86C302.06,-89.86 306.13,-98.58 314.16,-98.58 319.18,-98.58 322.66,-95.17 324.59,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"327.99,-90.78 326.15,-80.35 321.08,-89.66 327.99,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd406e21a90>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.25 0.75 0. ]\n",
|
||||
" [0.28571429 0.57142857 0.14285714]\n",
|
||||
" [0. 0.00826446 0.99173554]]\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"228pt\" height=\"124pt\"\n",
|
||||
" viewBox=\"0.00 0.00 228.00 124.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 119.83)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd4041662b0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1.]\n",
|
||||
" [0. 1.]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def explore_session(session_id: str):\n",
|
||||
" subset = df[df['sessionId'] == session_id] # not .where(...)\n",
|
||||
" P, labels = build_transition_prob_matrix(subset)\n",
|
||||
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
||||
" display(g)\n",
|
||||
" return P\n",
|
||||
"for session in sessions:\n",
|
||||
" print(explore_session(session))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4d278c2d-406e-4dc0-b219-5f7b236e852b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python (PHANTOM)",
|
||||
"language": "python",
|
||||
"name": "phantom"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
19
experiments/procesing/__init__.py
Normal file
19
experiments/procesing/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .extract import (
|
||||
KafkaDataFetcher,
|
||||
ExperimentJoiner,
|
||||
EventTitleAugmenter,
|
||||
)
|
||||
from .demand import DemandEstimator
|
||||
from .mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||
from .pipeline import etl_pipeline, pricing_pipeline
|
||||
|
||||
__all__ = [
|
||||
'KafkaDataFetcher',
|
||||
'ExperimentJoiner',
|
||||
'EventTitleAugmenter',
|
||||
'DemandEstimator',
|
||||
'SessionTransitionProbMatrixTransformer',
|
||||
'render_graph',
|
||||
'etl_pipeline',
|
||||
'pricing_pipeline',
|
||||
]
|
||||
119
experiments/procesing/demand.py
Normal file
119
experiments/procesing/demand.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from supabase import create_client, Client
|
||||
from typing import Optional, Literal
|
||||
import os
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
class ChunkInteractionsIntoSteps(BaseEstimator, TransformerMixin):
|
||||
"""
|
||||
Split interaction data into time windows for temporal analysis.
|
||||
Returns a list of dataframes, one per time window.
|
||||
"""
|
||||
def __init__(self,
|
||||
window_size:str='1h',
|
||||
ts_col:str='ts',
|
||||
return_metadata:bool=True):
|
||||
"""
|
||||
Args:
|
||||
window_size: pandas freq string ('1h', '30T', '1D', etc)
|
||||
ts_col: timestamp column name
|
||||
return_metadata: if True, return dict with metadata per chunk
|
||||
"""
|
||||
self.window_size = window_size
|
||||
self.ts_col = ts_col
|
||||
self.return_metadata = return_metadata
|
||||
|
||||
def fit(self, X):
|
||||
return self
|
||||
|
||||
def transform(self, interactions: pd.DataFrame):
|
||||
"""
|
||||
Returns:
|
||||
if return_metadata=False: list of dataframes, one per window
|
||||
if return_metadata=True: list of dicts with keys:
|
||||
- 'data': dataframe for this window
|
||||
- 'window_start': start timestamp
|
||||
- 'window_end': end timestamp
|
||||
- 'window_idx': integer index
|
||||
"""
|
||||
if interactions.empty:
|
||||
return []
|
||||
|
||||
df = interactions.copy()
|
||||
|
||||
# ensure timestamp is datetime
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[self.ts_col]):
|
||||
df[self.ts_col] = pd.to_datetime(df[self.ts_col])
|
||||
|
||||
# sort by time
|
||||
df = df.sort_values(self.ts_col)
|
||||
|
||||
# assign window
|
||||
df['_window'] = df[self.ts_col].dt.floor(self.window_size)
|
||||
|
||||
# group by window
|
||||
chunks = []
|
||||
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
||||
chunk_data = group.drop(columns=['_window'])
|
||||
|
||||
if self.return_metadata:
|
||||
chunks.append({
|
||||
'data': chunk_data,
|
||||
'window_start': window_start,
|
||||
'window_end': window_start + pd.Timedelta(self.window_size),
|
||||
'window_idx': idx
|
||||
})
|
||||
else:
|
||||
chunks.append(chunk_data)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
class DemandEstimator(BaseEstimator, TransformerMixin):
|
||||
def __init__(self,
|
||||
store_mode:str='hotel',
|
||||
session_filter:str="",
|
||||
experiment_filter:str=""):
|
||||
self.store=store_mode
|
||||
self.session_filter=session_filter if len(session_filter)>0 else None
|
||||
self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None
|
||||
def fit(self, X):
|
||||
return self
|
||||
|
||||
def transform(self, interactions : pd.DataFrame):
|
||||
if interactions.empty:
|
||||
return pd.DataFrame(columns=["productId", "demand_score"])
|
||||
if self.session_filter:
|
||||
interactions = interactions[interactions['sessionId'] == self.session_filter]
|
||||
if self.experiment_filter:
|
||||
interactions = interactions[interactions['experimentId'] == self.experiment_filter]
|
||||
|
||||
products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute()
|
||||
products = pd.DataFrame(products.data)
|
||||
unique_products = products['id'].unique()
|
||||
log.info(f"Demand estimator found {len(unique_products)} in data")
|
||||
|
||||
# filter out rows without productId
|
||||
interactions_with_products = interactions.dropna(subset=['productId'])
|
||||
|
||||
if interactions_with_products.empty:
|
||||
# no interactions with products, return all zeros
|
||||
return pd.DataFrame({
|
||||
'productId': unique_products,
|
||||
'demand_score': 0
|
||||
})
|
||||
|
||||
# TODO: improve demand score calculation rather than just counting interactions (use weights..)
|
||||
# while maintaining simplicity of a simple cross tab approach
|
||||
product_demand = pd.crosstab(interactions_with_products['productId'], "no_of_interactions")
|
||||
product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index()
|
||||
product_demand.columns = ['productId', 'demand_score']
|
||||
return product_demand
|
||||
333
experiments/procesing/elasticity.py
Normal file
333
experiments/procesing/elasticity.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import List, Dict, Optional
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
from supabase import create_client, Client
|
||||
import os
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
class TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
||||
"""
|
||||
Compute price elasticity from time-series demand and price data.
|
||||
|
||||
Elasticity = (% change in quantity) / (% change in price)
|
||||
|
||||
Works with chunked time-window data from ChunkInteractionsIntoSteps.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
method:str='point',
|
||||
min_observations:int=2,
|
||||
smooth_window:Optional[int]=None):
|
||||
"""
|
||||
Args:
|
||||
method: 'point' (point elasticity) or 'arc' (arc elasticity)
|
||||
min_observations: min data points needed per product
|
||||
smooth_window: if set, apply rolling avg smoothing to time series
|
||||
"""
|
||||
self.method = method
|
||||
self.min_observations = min_observations
|
||||
self.smooth_window = smooth_window
|
||||
|
||||
def fit(self, X):
|
||||
return self
|
||||
|
||||
def transform(self,
|
||||
demand_chunks: List[Dict],
|
||||
price_chunks: List[Dict],
|
||||
store_mode: str = 'hotel') -> pd.DataFrame:
|
||||
"""
|
||||
Args:
|
||||
demand_chunks: list from ChunkInteractionsIntoSteps + DemandEstimator
|
||||
each item: {'window_start', 'window_end', 'demand_vector'}
|
||||
price_chunks: list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||
store_mode: 'hotel' or 'airline' to fetch all products
|
||||
|
||||
Returns:
|
||||
df with [productId, elasticity, std_error, n_observations]
|
||||
"""
|
||||
# fetch all products from database
|
||||
all_products = supabase.table(f'{store_mode}_products').select("id").execute()
|
||||
all_product_ids = [p['id'] for p in all_products.data]
|
||||
|
||||
aligned = self._align_chunks(demand_chunks, price_chunks)
|
||||
if not aligned:
|
||||
# return all products with zero elasticity
|
||||
return pd.DataFrame({
|
||||
'productId': all_product_ids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
|
||||
# build time series per product
|
||||
product_series = self._build_product_timeseries(aligned)
|
||||
|
||||
# compute elasticity per product
|
||||
elasticities = []
|
||||
for pid, series in product_series.items():
|
||||
if len(series) < self.min_observations:
|
||||
# assign 0 elasticity for products with insufficient data
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': len(series)
|
||||
})
|
||||
continue
|
||||
|
||||
# apply smoothing if requested
|
||||
if self.smooth_window and len(series) >= self.smooth_window:
|
||||
series = self._smooth_series(series, self.smooth_window)
|
||||
|
||||
elast = self._compute_elasticity(series)
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': elast['value'],
|
||||
'std_error': elast.get('std_error', 0.0),
|
||||
'n_obs': len(series)
|
||||
})
|
||||
|
||||
result_df = pd.DataFrame(elasticities)
|
||||
|
||||
# fill in missing products with zero elasticity
|
||||
observed_pids = set(result_df['productId'].unique())
|
||||
missing_pids = [pid for pid in all_product_ids if pid not in observed_pids]
|
||||
|
||||
if missing_pids:
|
||||
missing_df = pd.DataFrame({
|
||||
'productId': missing_pids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
||||
|
||||
return result_df
|
||||
|
||||
def _align_chunks(self, demand_chunks, price_chunks):
|
||||
"""Align demand and price data by matching time windows."""
|
||||
aligned = []
|
||||
|
||||
# create lookup for price chunks by window_start
|
||||
price_lookup = {chunk['window_start']: chunk for chunk in price_chunks}
|
||||
|
||||
for demand_chunk in demand_chunks:
|
||||
window_start = demand_chunk['window_start']
|
||||
if window_start in price_lookup:
|
||||
aligned.append({
|
||||
'window_start': window_start,
|
||||
'window_end': demand_chunk['window_end'],
|
||||
'demand': demand_chunk['demand_vector'],
|
||||
'prices': price_lookup[window_start]['price_vector']
|
||||
})
|
||||
|
||||
return aligned
|
||||
|
||||
def _build_product_timeseries(self, aligned_chunks):
|
||||
"""Build time series [price, quantity] per product."""
|
||||
series_by_product = {}
|
||||
|
||||
for chunk in aligned_chunks:
|
||||
demand_df = chunk['demand']
|
||||
price_df = chunk['prices']
|
||||
|
||||
# merge on productId
|
||||
merged = demand_df.merge(price_df, on='productId', how='inner')
|
||||
|
||||
for _, row in merged.iterrows():
|
||||
pid = row['productId']
|
||||
if pid not in series_by_product:
|
||||
series_by_product[pid] = []
|
||||
|
||||
series_by_product[pid].append({
|
||||
'timestamp': chunk['window_start'],
|
||||
'price': row['price'],
|
||||
'quantity': row['demand_score']
|
||||
})
|
||||
|
||||
return series_by_product
|
||||
|
||||
def _smooth_series(self, series, window):
|
||||
"""Apply rolling average smoothing."""
|
||||
df = pd.DataFrame(series)
|
||||
df['price_smooth'] = df['price'].rolling(window=window, center=True).mean()
|
||||
df['quantity_smooth'] = df['quantity'].rolling(window=window, center=True).mean()
|
||||
df = df.dropna()
|
||||
|
||||
return [{'timestamp': row['timestamp'],
|
||||
'price': row['price_smooth'],
|
||||
'quantity': row['quantity_smooth']}
|
||||
for _, row in df.iterrows()]
|
||||
|
||||
def _compute_elasticity(self, series):
|
||||
"""Compute elasticity from time series."""
|
||||
if len(series) < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
prices = np.array([s['price'] for s in series])
|
||||
quantities = np.array([s['quantity'] for s in series])
|
||||
|
||||
# filter out zero/negative values
|
||||
valid = (prices > 0) & (quantities > 0)
|
||||
if valid.sum() < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
prices = prices[valid]
|
||||
quantities = quantities[valid]
|
||||
|
||||
if self.method == 'point':
|
||||
return self._point_elasticity(prices, quantities)
|
||||
elif self.method == 'arc':
|
||||
return self._arc_elasticity(prices, quantities)
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {self.method}")
|
||||
|
||||
def _point_elasticity(self, prices, quantities):
|
||||
"""
|
||||
Point elasticity using log-log regression.
|
||||
log(Q) = a + b*log(P), elasticity = b
|
||||
"""
|
||||
if len(prices) < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
log_p = np.log(prices)
|
||||
log_q = np.log(quantities)
|
||||
|
||||
# simple linear regression
|
||||
if log_p.std() == 0:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
cov = np.cov(log_p, log_q)[0, 1]
|
||||
var = np.var(log_p)
|
||||
b = cov / var
|
||||
|
||||
# std error estimate (avoid div by zero)
|
||||
if len(prices) <= 2:
|
||||
se_b = 0.0
|
||||
else:
|
||||
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
||||
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
||||
se_b = np.sqrt(mse / (len(prices) * var))
|
||||
|
||||
return {'value': b, 'std_error': se_b}
|
||||
|
||||
def _arc_elasticity(self, prices, quantities):
|
||||
"""
|
||||
Arc elasticity: average of period-over-period elasticities.
|
||||
E_t = (ΔQ/Q_avg) / (ΔP/P_avg)
|
||||
"""
|
||||
elasticities = []
|
||||
|
||||
for i in range(1, len(prices)):
|
||||
p1, p2 = prices[i-1], prices[i]
|
||||
q1, q2 = quantities[i-1], quantities[i]
|
||||
|
||||
p_avg = (p1 + p2) / 2
|
||||
q_avg = (q1 + q2) / 2
|
||||
|
||||
if p_avg == 0 or q_avg == 0:
|
||||
continue
|
||||
|
||||
delta_p = p2 - p1
|
||||
delta_q = q2 - q1
|
||||
|
||||
if delta_p == 0:
|
||||
continue
|
||||
|
||||
e = (delta_q / q_avg) / (delta_p / p_avg)
|
||||
elasticities.append(e)
|
||||
|
||||
if not elasticities:
|
||||
return None
|
||||
|
||||
return {
|
||||
'value': np.mean(elasticities),
|
||||
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
||||
}
|
||||
|
||||
|
||||
def aggregate_price_logs(price_logs: pd.DataFrame,
|
||||
window_size: str = '1H',
|
||||
ts_col: str = 'ts',
|
||||
store_mode : str = 'hotel') -> List[Dict]:
|
||||
"""
|
||||
Recover price vectors treating prices as persistent state changes.
|
||||
|
||||
Prices are set-operations that persist until next change. For each window:
|
||||
- If price logs exist: average all changes within window
|
||||
- If no logs: carry forward last price before window end
|
||||
|
||||
Args:
|
||||
price_logs: df with [productId, price, ts, ...]
|
||||
window_size: time window size matching ChunkInteractionsIntoSteps
|
||||
ts_col: timestamp column name
|
||||
|
||||
Returns:
|
||||
list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||
where price_vector is df with [productId, price]
|
||||
"""
|
||||
if price_logs.empty:
|
||||
return []
|
||||
|
||||
df = price_logs.copy()
|
||||
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||
|
||||
df = df.sort_values([ts_col, 'productId'])
|
||||
all_products=supabase.table(f'{store_mode}_products').select("id, room_type, date_index, metadata, availability").execute()
|
||||
all_products = pd.DataFrame(all_products.data)
|
||||
unique_products = all_products['id'].unique()
|
||||
|
||||
# generate windows across data range
|
||||
min_time, max_time = df[ts_col].min(), df[ts_col].max()
|
||||
windows = pd.date_range(
|
||||
start=min_time.floor(window_size),
|
||||
end=max_time,
|
||||
freq=window_size
|
||||
)
|
||||
|
||||
chunks = []
|
||||
|
||||
for window_start in windows:
|
||||
window_end = window_start + pd.Timedelta(window_size)
|
||||
price_vector = []
|
||||
|
||||
# all products with price history by window_end
|
||||
#historical_products = df[df[ts_col] < window_end]['productId'].unique()
|
||||
historical_products = unique_products.tolist()
|
||||
|
||||
for pid in historical_products:
|
||||
product_data = df[df['productId'] == pid]
|
||||
|
||||
# logs within window
|
||||
in_window = product_data[
|
||||
(product_data[ts_col] >= window_start) &
|
||||
(product_data[ts_col] < window_end)
|
||||
]
|
||||
|
||||
if not in_window.empty:
|
||||
# average changes within window
|
||||
price = in_window['price'].mean()
|
||||
else:
|
||||
# carry forward: last price before window end
|
||||
before_window = product_data[product_data[ts_col] < window_end]
|
||||
if before_window.empty:
|
||||
continue
|
||||
price = before_window['price'].iloc[-1]
|
||||
|
||||
price_vector.append({'productId': pid, 'price': price})
|
||||
|
||||
if price_vector:
|
||||
chunks.append({
|
||||
'window_start': window_start,
|
||||
'window_end': window_end,
|
||||
'price_vector': pd.DataFrame(price_vector)
|
||||
})
|
||||
|
||||
return chunks
|
||||
207
experiments/procesing/extract.py
Normal file
207
experiments/procesing/extract.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import pandas as pd
|
||||
import json
|
||||
import numpy as np
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
from supabase import create_client, Client
|
||||
from typing import Tuple, List, Dict
|
||||
load_dotenv()
|
||||
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000")
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
N_PRICE_BUCKETS = 5
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
|
||||
class KafkaDataFetcher(BaseEstimator, TransformerMixin):
|
||||
def __init__(self, topic: str = "user-interactions"):
|
||||
self.topic = topic # also can be price-logs
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, X=None):
|
||||
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump?topic={self.topic}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not data.get('success') or not data.get('data'):
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(data['data'])
|
||||
if self.topic == 'user-interactions':
|
||||
if 'metadata' in df.columns: # explode metadata col json
|
||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
||||
df = df.dropna(subset=['eventName'])
|
||||
# remape dateIndex
|
||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||
return df
|
||||
|
||||
|
||||
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, df):
|
||||
if df.empty or 'experimentId' not in df.columns:
|
||||
return df
|
||||
|
||||
unique_exp_ids = df['experimentId'].dropna().unique()
|
||||
if len(unique_exp_ids) == 0:
|
||||
return df
|
||||
|
||||
resp = supabase.table('experiments').select(
|
||||
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)'
|
||||
).in_('id', unique_exp_ids.tolist()).execute()
|
||||
|
||||
if not resp.data:
|
||||
return df
|
||||
|
||||
exp_df = pd.DataFrame(resp.data)
|
||||
|
||||
# flatten task nested object if present
|
||||
if 'task' in exp_df.columns and exp_df['task'].notnull().any():
|
||||
task_normalized = pd.json_normalize(exp_df['task'].dropna())
|
||||
task_normalized.index = exp_df[exp_df['task'].notnull()].index
|
||||
exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task')
|
||||
|
||||
# rename experiment columns for clarity
|
||||
exp_df = exp_df.rename(columns={
|
||||
'id': 'experimentId',
|
||||
'subject_name': 'exp_subject',
|
||||
'xp_human_only': 'exp_human_only',
|
||||
'xp_market_mode': 'exp_market_mode',
|
||||
'xp_task_id': 'exp_task_id'
|
||||
})
|
||||
|
||||
df = df.merge(exp_df, on='experimentId', how='left')
|
||||
return df
|
||||
|
||||
|
||||
class EventTitleAugmenter(BaseEstimator, TransformerMixin):
|
||||
def fit(self, X=None, y=None):
|
||||
return self
|
||||
|
||||
def transform(self, df):
|
||||
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
||||
# we want metadata schema to create product specific event names
|
||||
|
||||
# only create price buckets if we have enough unique prices
|
||||
if df["metadata_price"].notnull().sum() > 0:
|
||||
try:
|
||||
price_buckets = pd.qcut(
|
||||
df["metadata_price"],
|
||||
q=N_PRICE_BUCKETS,
|
||||
labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)],
|
||||
duplicates='drop' # handle duplicate bin edges
|
||||
)
|
||||
except ValueError:
|
||||
# fallback: if still not enough unique values, use cut with fixed ranges or just use raw price
|
||||
price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "")
|
||||
else:
|
||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||
|
||||
# metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name
|
||||
# TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page
|
||||
df["metadata_schema"] = np.where(
|
||||
df["productId"].notnull() & df["metadata_price"].notnull(),
|
||||
"_" + df["productId"].astype(str) + "@" + price_buckets.astype(str),
|
||||
""
|
||||
)
|
||||
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
|
||||
return df
|
||||
|
||||
|
||||
def chunk_shared_data(interactions_df: pd.DataFrame,
|
||||
price_logs_df: pd.DataFrame,
|
||||
window_size: str = '30s',
|
||||
ts_col: str = 'ts') -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Chunk interaction and price data into aligned time windows.
|
||||
|
||||
Args:
|
||||
interactions_df: interaction data with timestamp column
|
||||
price_logs_df: price log data with timestamp column
|
||||
window_size: pandas freq string ('30s', '1min', '1h', etc)
|
||||
ts_col: name of timestamp column
|
||||
|
||||
Returns:
|
||||
tuple of (interaction_chunks, price_chunks) where each is list of dicts:
|
||||
{
|
||||
'window_start': timestamp,
|
||||
'window_end': timestamp,
|
||||
'data': dataframe for this window
|
||||
}
|
||||
"""
|
||||
if interactions_df.empty and price_logs_df.empty:
|
||||
return [], []
|
||||
|
||||
# convert timestamps to datetime
|
||||
interactions_df = interactions_df.copy()
|
||||
price_logs_df = price_logs_df.copy()
|
||||
|
||||
if not interactions_df.empty:
|
||||
if not pd.api.types.is_datetime64_any_dtype(interactions_df[ts_col]):
|
||||
interactions_df[ts_col] = pd.to_datetime(interactions_df[ts_col])
|
||||
|
||||
if not price_logs_df.empty:
|
||||
if not pd.api.types.is_datetime64_any_dtype(price_logs_df[ts_col]):
|
||||
price_logs_df[ts_col] = pd.to_datetime(price_logs_df[ts_col])
|
||||
|
||||
# find global time bounds
|
||||
times = []
|
||||
if not interactions_df.empty:
|
||||
times.extend([interactions_df[ts_col].min(), interactions_df[ts_col].max()])
|
||||
if not price_logs_df.empty:
|
||||
times.extend([price_logs_df[ts_col].min(), price_logs_df[ts_col].max()])
|
||||
|
||||
if not times:
|
||||
return [], []
|
||||
|
||||
earliest = min(times)
|
||||
latest = max(times)
|
||||
|
||||
# create shared time windows
|
||||
windows = pd.date_range(start=earliest, end=latest, freq=window_size)
|
||||
|
||||
if len(windows) < 2:
|
||||
return [], []
|
||||
|
||||
# chunk both datasets
|
||||
interaction_chunks = []
|
||||
price_chunks = []
|
||||
|
||||
for i in range(len(windows) - 1):
|
||||
window_start = windows[i]
|
||||
window_end = windows[i + 1]
|
||||
|
||||
# filter interactions in this window
|
||||
if not interactions_df.empty:
|
||||
mask = (interactions_df[ts_col] >= window_start) & (interactions_df[ts_col] < window_end)
|
||||
interaction_chunk = interactions_df[mask]
|
||||
else:
|
||||
interaction_chunk = pd.DataFrame()
|
||||
|
||||
interaction_chunks.append({
|
||||
'window_start': window_start,
|
||||
'window_end': window_end,
|
||||
'data': interaction_chunk
|
||||
})
|
||||
|
||||
# filter price logs in this window
|
||||
if not price_logs_df.empty:
|
||||
mask = (price_logs_df[ts_col] >= window_start) & (price_logs_df[ts_col] < window_end)
|
||||
price_chunk = price_logs_df[mask]
|
||||
else:
|
||||
price_chunk = pd.DataFrame()
|
||||
|
||||
price_chunks.append({
|
||||
'window_start': window_start,
|
||||
'window_end': window_end,
|
||||
'data': price_chunk
|
||||
})
|
||||
|
||||
return interaction_chunks, price_chunks
|
||||
158
experiments/procesing/mapping.py
Normal file
158
experiments/procesing/mapping.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
|
||||
def build_transition_prob_matrix(df: pd.DataFrame):
|
||||
df = df.dropna(subset=['eventName'])
|
||||
events = df['eventName'].tolist()
|
||||
labels = pd.Index(events).unique().tolist()
|
||||
idx = {e:i for i,e in enumerate(labels)}
|
||||
M = np.zeros((len(labels), len(labels)), dtype=float)
|
||||
for a, b in zip(events, events[1:]):
|
||||
M[idx[a], idx[b]] += 1
|
||||
row_sums = M.sum(axis=1, keepdims=True)
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
P = np.divide(M, row_sums, where=row_sums>0) # row-normalized
|
||||
return P, labels
|
||||
|
||||
# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b
|
||||
from graphviz import Digraph
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
def _as_prob_df(matrix, labels=None):
|
||||
"""Return a square DataFrame with index=columns=labels."""
|
||||
if isinstance(matrix, pd.DataFrame):
|
||||
# Ensure square and aligned
|
||||
assert (matrix.index == matrix.columns).all(), "Index/columns must match."
|
||||
return matrix
|
||||
matrix = np.asarray(matrix, dtype=float)
|
||||
assert matrix.shape[0] == matrix.shape[1], "Matrix must be square."
|
||||
if labels is None:
|
||||
raise ValueError("labels are required when matrix is not a DataFrame")
|
||||
assert len(labels) == matrix.shape[0], "labels length must match matrix size."
|
||||
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
|
||||
|
||||
def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):
|
||||
"""Build weighted edges > threshold."""
|
||||
edges = []
|
||||
for src in P.index:
|
||||
for dst in P.columns:
|
||||
w = float(P.loc[src, dst])
|
||||
if w > threshold:
|
||||
edges.append((str(src), str(dst), f"{w:.{round_digits}f}"))
|
||||
return edges
|
||||
|
||||
def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt="svg", view=False):
|
||||
"""
|
||||
fname: output file stem (no extension)
|
||||
matrix: NumPy array or pandas DataFrame of transition PROBABILITIES
|
||||
ls_index: ordered labels (required if matrix is not a DataFrame)
|
||||
threshold: hide edges with weight <= threshold
|
||||
fmt: 'svg'|'png'|'pdf' etc.
|
||||
view: open after rendering
|
||||
"""
|
||||
P = _as_prob_df(matrix, labels=ls_index)
|
||||
edges = _df_to_edgelist(P, threshold=threshold)
|
||||
|
||||
g = Digraph(format=fmt)
|
||||
g.attr(rankdir="LR", size="30")
|
||||
g.attr("node", shape="circle")
|
||||
|
||||
# ensure isolated nodes appear
|
||||
for node in P.index:
|
||||
g.node(str(node), width="1", height="1")
|
||||
|
||||
for src, dst, label in edges:
|
||||
g.edge(src, dst, label=label)
|
||||
|
||||
g.render(fname, view=view, cleanup=True)
|
||||
return g
|
||||
|
||||
|
||||
class TransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
||||
def __init__(self, threshold=0.0):
|
||||
self.threshold = threshold
|
||||
self.P_ = None
|
||||
self.labels_ = None
|
||||
|
||||
def fit(self, X: pd.DataFrame, y=None):
|
||||
P, labels = build_transition_prob_matrix(X)
|
||||
self.P_ = P
|
||||
self.labels_ = labels
|
||||
return self
|
||||
|
||||
def transform(self, X: pd.DataFrame = None):
|
||||
return self.P_, self.labels_
|
||||
|
||||
def render(self, fname: str, fmt="svg", view=False):
|
||||
if self.P_ is None or self.labels_ is None:
|
||||
raise ValueError("Transformer has not been fitted yet.")
|
||||
return render_graph(
|
||||
fname,
|
||||
self.P_,
|
||||
ls_index=self.labels_,
|
||||
threshold=self.threshold,
|
||||
fmt=fmt,
|
||||
view=view
|
||||
)
|
||||
|
||||
|
||||
class SessionTransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
||||
def __init__(self, threshold=0.0, session_col='sessionId'):
|
||||
self.threshold = threshold
|
||||
self.session_col = session_col
|
||||
self.session_matrices_ = None
|
||||
|
||||
def fit(self, X: pd.DataFrame, y=None):
|
||||
if self.session_col not in X.columns:
|
||||
raise ValueError(f"Column '{self.session_col}' not found in DataFrame")
|
||||
|
||||
session_matrices = {}
|
||||
for session_id, grp in X.groupby(self.session_col):
|
||||
if len(grp) > 1: # need at least 2 events for transitions
|
||||
P, labels = build_transition_prob_matrix(grp)
|
||||
session_matrices[session_id] = {'matrix': P, 'labels': labels}
|
||||
|
||||
self.session_matrices_ = session_matrices
|
||||
return self
|
||||
|
||||
def transform(self, X: pd.DataFrame = None):
|
||||
if self.session_matrices_ is None:
|
||||
raise ValueError("Transformer has not been fitted yet.")
|
||||
return pd.Series(self.session_matrices_)
|
||||
|
||||
def render_session(self, session_id: str, fname: str, fmt="svg", view=False):
|
||||
if self.session_matrices_ is None:
|
||||
raise ValueError("Transformer has not been fitted yet.")
|
||||
if session_id not in self.session_matrices_:
|
||||
raise ValueError(f"Session '{session_id}' not found in fitted data.")
|
||||
|
||||
sess_data = self.session_matrices_[session_id]
|
||||
return render_graph(
|
||||
fname,
|
||||
sess_data['matrix'],
|
||||
ls_index=sess_data['labels'],
|
||||
threshold=self.threshold,
|
||||
fmt=fmt,
|
||||
view=view
|
||||
)
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
data = {
|
||||
'eventName': [
|
||||
'A', 'B', 'A', 'C', 'B', 'A', 'A', 'C', 'B', 'C',
|
||||
'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'
|
||||
]
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
transformer = TransitionProbMatrixTransformer(threshold=0.1)
|
||||
transformer.fit(df)
|
||||
P, labels = transformer.transform(None)
|
||||
|
||||
print("Transition Probability Matrix:")
|
||||
print(pd.DataFrame(P, index=labels, columns=labels))
|
||||
|
||||
# Render the graph
|
||||
transformer.render("transition_graph", fmt="svg", view=False)
|
||||
90
experiments/procesing/pipeline.py
Normal file
90
experiments/procesing/pipeline.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
import pandas as pd
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter, chunk_shared_data
|
||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||
from demand import DemandEstimator, ChunkInteractionsIntoSteps
|
||||
from elasticity import TemporalElasticityEstimator, aggregate_price_logs
|
||||
|
||||
|
||||
|
||||
# elasticity pipeline components (not sklearn compatible, manual orchestration)
|
||||
def elasticity_pipeline(interactions_df, price_logs_df, window_size='30s', store_mode='hotel'):
|
||||
"""
|
||||
Compute price elasticity from interaction and price data.
|
||||
|
||||
Args:
|
||||
interactions_df: raw interaction data from demand_data_pipeline
|
||||
price_logs_df: price log data from price_data_pipeline
|
||||
window_size: time window for chunking
|
||||
store_mode: 'hotel' or 'airline'
|
||||
|
||||
Returns:
|
||||
df with [productId, elasticity, std_error, n_obs]
|
||||
"""
|
||||
# step 1: chunk interactions into time windows
|
||||
chunker = ChunkInteractionsIntoSteps(window_size=window_size, return_metadata=True)
|
||||
interaction_chunks = chunker.transform(interactions_df)
|
||||
log.info(f"Chunked interactions into {len(interaction_chunks)} windows of size {window_size}")
|
||||
|
||||
if not interaction_chunks:
|
||||
return None
|
||||
|
||||
# step 2: compute demand per window
|
||||
demand_estimator = DemandEstimator(store_mode=store_mode)
|
||||
demand_chunks = []
|
||||
for chunk in interaction_chunks:
|
||||
demand_vector = demand_estimator.transform(chunk['data'])
|
||||
demand_chunks.append({
|
||||
'window_start': chunk['window_start'],
|
||||
'window_end': chunk['window_end'],
|
||||
'demand_vector': demand_vector # each has a full list of all products, even if demand is 0
|
||||
})
|
||||
# [q_chunk1, q_chunk2, ...]
|
||||
|
||||
# step 3: aggregate price logs into windows
|
||||
price_chunks = aggregate_price_logs(price_logs_df, window_size=window_size)
|
||||
|
||||
# step 4: compute elasticity
|
||||
elasticity_estimator = TemporalElasticityEstimator(method='point', min_observations=2)
|
||||
elasticity_df = elasticity_estimator.transform(demand_chunks, price_chunks, store_mode=store_mode)
|
||||
|
||||
return elasticity_df
|
||||
|
||||
|
||||
# exposable pipelines
|
||||
interaction_pipeline = Pipeline([
|
||||
('kafka_fetch', KafkaDataFetcher(topic='user-interactions')),
|
||||
('experiment_join', ExperimentJoiner()),
|
||||
('event_augment', EventTitleAugmenter()),
|
||||
])
|
||||
|
||||
price_data_pipeline = Pipeline([
|
||||
('kafka_fetch', KafkaDataFetcher(topic='price-logs')),
|
||||
])
|
||||
|
||||
# interaction_data + price_data -> elasticity (demand)
|
||||
# elasticity -> pricing
|
||||
|
||||
pricing_pipeline = Pipeline([
|
||||
('demand_estimation', DemandEstimator()),
|
||||
])
|
||||
if __name__ == "__main__":
|
||||
# fetch both datasets
|
||||
interaction_data = interaction_pipeline.fit_transform(None)
|
||||
pricing_data = price_data_pipeline.fit_transform(None)
|
||||
if interaction_data.empty or pricing_data.empty:
|
||||
print("Insufficient data for elasticity computation"); exit(0)
|
||||
# compute elasticity via unified pipeline
|
||||
window_size = "30s"
|
||||
elasticity_results = elasticity_pipeline(interaction_data, pricing_data, window_size=window_size)
|
||||
elasticity_value_array = elasticity_results['elasticity'].values if elasticity_results is not None else np.array([])
|
||||
print(elasticity_value_array)
|
||||
|
||||
if elasticity_results is not None and not elasticity_results.empty:
|
||||
print(elasticity_results.to_string(index=False))
|
||||
else:
|
||||
print("\nInsufficient data for elasticity computation")
|
||||
153
experiments/procesing/pricing.py
Normal file
153
experiments/procesing/pricing.py
Normal file
@@ -0,0 +1,153 @@
|
||||
r"""
|
||||
Our state space comes as:
|
||||
$Q_t in R^n$ - our demand at a time t
|
||||
$P_t in R^n$ - prices at time t
|
||||
$S_t$ some form of interaction session features
|
||||
|
||||
This is a single sate which we map under
|
||||
|
||||
$f: (Q, S, H) \to P_{t+1}$
|
||||
|
||||
With:
|
||||
|
||||
$H_t = \{Q_{t-k}, P_{t-k}, S_{t-k}\}$
|
||||
|
||||
|
||||
We can have f be literally anything, analytical or learned or rule based or an RL policy.
|
||||
|
||||
Our goal is to mazimize the expected revenue:
|
||||
|
||||
$E[R_T] = E[\sum_{t=1}^T P_t^T \dot Q_t]$
|
||||
|
||||
subject to Q_t = g(P_t, S_t) : demand response to price (estimated via elasticity) and P_t ≥ C : prices above cost floor and additionally minimizing the following:
|
||||
|
||||
$L_{agent} = R_{oracle} - R_{observed}
|
||||
|
||||
where: R_oracle = revenue if we knew agent intentions (from recon session) and R_observed = revenue under current pricing policy f
|
||||
|
||||
I would start be defning a pricing function interface and standardizing how to train that based on historical data and define how to make it behave for online training (if we do that)
|
||||
|
||||
We also need to develop a solid benchmark with mapping revenue and full KPIs from session interactions to measure differences between different price learning methods
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import os
|
||||
from supabase import create_client, Client
|
||||
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
def expected_revenue(prices: np.ndarray, demand: np.ndarray) -> float:
|
||||
"""Returns: expected revenue R_t = P_t^T * Q_t"""
|
||||
return float(np.dot(prices, demand))
|
||||
|
||||
class StateSpace:
|
||||
def __init__(self,
|
||||
demand : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||
prices : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||
session_features : pd.DataFrame):
|
||||
self.demand = demand # Q_t
|
||||
self.prices = prices # P_t
|
||||
self.session_features = session_features # S_t
|
||||
self.history = [] # H_t
|
||||
|
||||
class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def fit(self, historical_data):
|
||||
"""
|
||||
Train the pricing function based on historical data.
|
||||
historical_data: list of StateSpace instances with known outcomes
|
||||
"""
|
||||
raise NotImplementedError("Train method must be implemented by subclass.")
|
||||
|
||||
def transform(self, state_space) -> np.ndarray:
|
||||
"""
|
||||
Predict the next prices given the current state space.
|
||||
state_space: StateSpace instance
|
||||
Returns: predicted prices P_{t+1}
|
||||
"""
|
||||
raise NotImplementedError("Predict method must be implemented by subclass.")
|
||||
|
||||
|
||||
class SimpleLinearPricingFunction(PricingFunction):
|
||||
def __init__(self, price_sensitivity: float = -0.1):
|
||||
super().__init__()
|
||||
self.price_sensitivity = price_sensitivity # simple coefficient
|
||||
|
||||
def fit(self, historical_data):
|
||||
return self
|
||||
|
||||
def transform(self, state_space: StateSpace) -> np.ndarray:
|
||||
# Simple linear adjustment: P_{t+1} = P_t + sensitivity * Q_t
|
||||
new_prices = state_space.prices + self.price_sensitivity * state_space.demand # this is not great
|
||||
return np.maximum(new_prices, 0)
|
||||
|
||||
# Example usage:
|
||||
if __name__ == "__main__":
|
||||
store_mode = 'hotel'
|
||||
interaction_data = interaction_pipeline.fit_transform(None)
|
||||
price_data = price_data_pipeline.fit_transform(None)
|
||||
|
||||
elasticity_df = elasticity_pipeline(interaction_data, price_data, window_size="30s", store_mode=store_mode)
|
||||
|
||||
# fetch all products with base prices from database
|
||||
products_resp = supabase.table(f'{store_mode}_products').select("id, metadata").execute()
|
||||
products_df = pd.DataFrame(products_resp.data)
|
||||
|
||||
# extract base_price from metadata
|
||||
products_df['base_price'] = products_df['metadata'].apply(lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0)
|
||||
products_df = products_df.rename(columns={'id': 'productId'})[['productId', 'base_price']]
|
||||
|
||||
# override with logged prices where available
|
||||
if not price_data.empty:
|
||||
if 'ts' in price_data.columns and not pd.api.types.is_datetime64_any_dtype(price_data['ts']):
|
||||
price_data['ts'] = pd.to_datetime(price_data['ts'])
|
||||
|
||||
# get latest logged price per product
|
||||
price_logs_agg = price_data.sort_values('ts').groupby('productId', as_index=False).last()
|
||||
|
||||
# merge: start with all products (base prices), override with logged prices
|
||||
products_df = products_df.merge(
|
||||
price_logs_agg[['productId', 'price']],
|
||||
on='productId',
|
||||
how='left'
|
||||
)
|
||||
products_df['final_price'] = products_df['price'].fillna(products_df['base_price'])
|
||||
else:
|
||||
products_df['final_price'] = products_df['base_price']
|
||||
|
||||
# merge with elasticity
|
||||
if elasticity_df is not None and not elasticity_df.empty:
|
||||
price_data_merged = products_df[['productId', 'final_price']].merge(
|
||||
elasticity_df[['productId', 'elasticity']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'elasticity': 0.0})
|
||||
|
||||
prices = price_data_merged['final_price'].values
|
||||
elasticities = price_data_merged['elasticity'].values
|
||||
else:
|
||||
prices = np.array([])
|
||||
elasticities = np.array([])
|
||||
|
||||
print(elasticities)
|
||||
print(prices)
|
||||
|
||||
state_space = StateSpace(
|
||||
demand=elasticities,
|
||||
prices=prices,
|
||||
session_features=interaction_data
|
||||
)
|
||||
|
||||
pricing_function = SimpleLinearPricingFunction(price_sensitivity=-0.05)
|
||||
pricing_function.fit([]) # No training data for simple model
|
||||
predicted_prices = pricing_function.transform(state_space)
|
||||
|
||||
print("Predicted Prices:", predicted_prices)
|
||||
125
experiments/seed_products.py
Normal file
125
experiments/seed_products.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from supabase import create_client, Client
|
||||
from tqdm import tqdm
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
||||
|
||||
if not SUPABASE_SERVICE_KEY:
|
||||
log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment")
|
||||
raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations")
|
||||
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
||||
|
||||
DAYS = 14
|
||||
|
||||
# hotel room configurations
|
||||
ROOMS = {
|
||||
"Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4},
|
||||
"Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3},
|
||||
"Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2},
|
||||
"Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2},
|
||||
"Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2},
|
||||
"Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2},
|
||||
}
|
||||
|
||||
# flight configurations
|
||||
FLIGHTS = {
|
||||
"JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250},
|
||||
"JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850},
|
||||
"ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180},
|
||||
"SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420},
|
||||
"ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600},
|
||||
"LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120},
|
||||
"MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380},
|
||||
"DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160},
|
||||
"SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720},
|
||||
"LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850},
|
||||
}
|
||||
|
||||
def gen_hotel_products():
|
||||
"""generate hotel room products for next DAYS days"""
|
||||
data = []
|
||||
for day in range(DAYS):
|
||||
for room_type, rdata in ROOMS.items():
|
||||
data.append({
|
||||
'room_type': room_type,
|
||||
'date_index': day + 1,
|
||||
'metadata': rdata,
|
||||
'availability': random.randint(0, rdata['total'])
|
||||
})
|
||||
return data
|
||||
|
||||
def gen_airline_products():
|
||||
"""generate flight products for next DAYS days"""
|
||||
data = []
|
||||
for day in range(DAYS):
|
||||
for flight_type, fdata in FLIGHTS.items():
|
||||
data.append({
|
||||
'flight_type': flight_type,
|
||||
'date_index': day + 1,
|
||||
'metadata': fdata,
|
||||
'availability': random.randint(0, fdata['total'])
|
||||
})
|
||||
return data
|
||||
|
||||
def clear_table(table_name: str):
|
||||
"""clear all records from a table"""
|
||||
try:
|
||||
resp = supabase.table(table_name).select('id').execute()
|
||||
if resp.data:
|
||||
ids = [row['id'] for row in resp.data]
|
||||
chunk_size = 100
|
||||
for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"):
|
||||
chunk = ids[i:i+chunk_size]
|
||||
supabase.table(table_name).delete().in_('id', chunk).execute()
|
||||
log.info(f"Deleted {len(ids)} records from {table_name}")
|
||||
else:
|
||||
log.info(f"{table_name} already empty")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to clear {table_name}: {e}")
|
||||
raise
|
||||
|
||||
def seed_table(table_name: str, data: list[dict]):
|
||||
"""insert records into a table"""
|
||||
try:
|
||||
chunk_size = 100
|
||||
total = len(data)
|
||||
for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"):
|
||||
chunk = data[i:i+chunk_size]
|
||||
supabase.table(table_name).insert(chunk).execute()
|
||||
log.info(f"Inserted {total} records into {table_name}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to seed {table_name}: {e}")
|
||||
raise
|
||||
|
||||
def main():
|
||||
|
||||
log.info("Generating hotel products...")
|
||||
hotel_products = gen_hotel_products()
|
||||
log.info(f"Generated {len(hotel_products)} hotel products")
|
||||
|
||||
log.info("Generating airline products...")
|
||||
airline_products = gen_airline_products()
|
||||
log.info(f"Generated {len(airline_products)} airline products\n")
|
||||
|
||||
log.info("Clearing existing products...")
|
||||
clear_table('hotel_products')
|
||||
clear_table('airline_products')
|
||||
|
||||
log.info("Seeding products...")
|
||||
seed_table('hotel_products', hotel_products)
|
||||
seed_table('airline_products', airline_products)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,11 +16,15 @@ mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
add_file() {
|
||||
local filepath="$1"
|
||||
local relpath="${filepath#$PROJECT_ROOT/}"
|
||||
local escaped_path="${relpath//_/\\_}"
|
||||
|
||||
# Add section header and code listing (no language-specific highlighting)
|
||||
echo "\\subsection{${relpath}}" >> "$OUTPUT_FILE"
|
||||
echo "\\begin{lstlisting}[caption={${relpath}}]" >> "$OUTPUT_FILE"
|
||||
cat "$filepath" >> "$OUTPUT_FILE"
|
||||
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
||||
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
||||
# Convert to ASCII: transliterate what's possible, drop the rest
|
||||
# LC_ALL=C forces ASCII locale for consistent behavior across environments
|
||||
LC_ALL=C iconv -f UTF-8 -t ASCII//TRANSLIT//IGNORE "$filepath" 2>/dev/null >> "$OUTPUT_FILE" || \
|
||||
LC_ALL=C tr -cd '\11\12\15\40-\176' < "$filepath" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
@@ -6,14 +6,19 @@
|
||||
(setq TeX-command-extra-options
|
||||
"-file-line-error -interaction=nonstopmode")
|
||||
(TeX-add-to-alist 'LaTeX-provided-class-options
|
||||
'(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm")))
|
||||
'(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false")))
|
||||
(TeX-run-style-hooks
|
||||
"latex2e"
|
||||
"preamble"
|
||||
"chapters/01-intro"
|
||||
"chapters/02-literature-review"
|
||||
"chapters/03-methodology"
|
||||
"chapters/04-results"
|
||||
"chapters/05-discussion"
|
||||
"chapters/06-conclusion"
|
||||
"../build/concatenated_code"
|
||||
"acmart"
|
||||
"acmart10")
|
||||
(TeX-add-symbols
|
||||
'("footnotetextcopyrightpermission" 1)))
|
||||
:latex)
|
||||
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
%% \label{fig:example}
|
||||
%% \end{figure}
|
||||
|
||||
\section{Know They Enemy}
|
||||
To know how to overcome we need to
|
||||
\section{Introduction}
|
||||
|
||||
Research Objectives and Contribution: What are we making, why and who should care?
|
||||
|
||||
\subsection{Motivation and Market Context}
|
||||
Current market dynamics and trends of dynamic pricing and AI agents. Future projections of AI agents. Key stakeholders that are discussing this and reporting on it (Thales). Who is most affected
|
||||
\subsection{Solution Space Overview}
|
||||
Different approaches and perspectives, here also add a preview of what will be developed and explored in the lit review.
|
||||
|
||||
17
paper/src/chapters/02-literature-review.tex
Normal file
17
paper/src/chapters/02-literature-review.tex
Normal file
@@ -0,0 +1,17 @@
|
||||
\section{Literature Review}
|
||||
|
||||
\subsection{Foundational Concepts}
|
||||
|
||||
What is the taxonomy and definition of an agent and an actor in this case, a bit more about interaction models in sessions and about dynamic pricing algorithms.
|
||||
|
||||
\subsection{Problem Evidence and Market Impact}
|
||||
Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries
|
||||
|
||||
\subsection{Theoretical Foundations: Economic Prallels}
|
||||
|
||||
Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance
|
||||
|
||||
\subsection{Landscape of Existing Work}
|
||||
|
||||
Previous efforts in adversarial computer use LLM agents, show how multi-faceted the whole problem is
|
||||
Here we can show a market visualization (venn-like-diagram)
|
||||
68
paper/src/chapters/03-methodology.tex
Normal file
68
paper/src/chapters/03-methodology.tex
Normal file
@@ -0,0 +1,68 @@
|
||||
\section{Methodology}
|
||||
|
||||
|
||||
\subsection{Problem Formalization}
|
||||
|
||||
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 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$
|
||||
|
||||
|
||||
\subsection{Cost of Information Framework}
|
||||
|
||||
Mathematical demonstration and validation of the COI and citation backed evidence, and framework overview + show harm to user via other cost distortions. Maybe split into 3.2.1 (COI Theory) and 3.2.2 (Framework Design)
|
||||
|
||||
\subsection{System Architecture}
|
||||
\begin{figure}[ht]
|
||||
\centering
|
||||
\begin{tikzpicture}[
|
||||
node distance=1.5cm and 2.5cm,
|
||||
box/.style={rectangle, draw, thick, minimum height=1cm, minimum width=3cm, align=center, fill=blue!10},
|
||||
kafka/.style={rectangle, draw=orange, thick, minimum height=1cm, minimum width=3cm, align=center, fill=orange!15},
|
||||
arrow/.style={thick,->,>=Stealth}
|
||||
]
|
||||
|
||||
% Nodes
|
||||
\node[box] (webapp) {Web Application \\ (Producer \& Consumer)};
|
||||
\node[kafka, below=of webapp] (kafka) {Apache Kafka \\ Cluster};
|
||||
\node[box, below=of kafka] (backend) {Backend Services / Microservices \\ (Producers and Consumers)};
|
||||
|
||||
% Connections
|
||||
\draw[arrow] (webapp) to[out=210,in=150] node[above]{Publish} (kafka);
|
||||
\draw[arrow] (kafka) to[out=50,in=330] node[below]{Consume} (webapp);
|
||||
\draw[arrow] (backend) -- node[above]{Publish/Consume} (kafka);
|
||||
|
||||
% Optional: Kafka internal components
|
||||
%\node[below=0.7cm of kafka, align=center] (topics) {Topics \\ Partitions};
|
||||
|
||||
% Optional background
|
||||
\begin{scope}[on background layer]
|
||||
\node[draw, rounded corners, fill=orange!5, fit=(kafka), inner sep=0.3cm] {};
|
||||
\end{scope}
|
||||
\end{tikzpicture}
|
||||
\caption{Technical Diagram}
|
||||
\end{figure}
|
||||
|
||||
High level overview of how it works
|
||||
\subsection{Experimental Design}
|
||||
Study methodology and approach. Data acquisition strategy. Defined objectives and success criteria. Observable metrics and KPIs
|
||||
|
||||
\subsection{Dynamic Pricing Algorithm Analysis}
|
||||
Deep dive into how the algorithm works, different kinds and justification for chosen appraoches + agent impact modeling and quantification.
|
||||
\subsection{Reinforcement Learning Formulation}
|
||||
How do we define the state space, action space and reward function breakdown and algorithm benchmarking.
|
||||
POSSIBLY: Expand into full subsections: 3.6.1 (State-Action Space), 3.6.2 (Reward Design), 3.6.3 (Benchmarking)
|
||||
|
||||
|
||||
\begin{algorithm}[t]
|
||||
\DontPrintSemicolon
|
||||
\KwIn{stepsize $\eta$, smoothing $\delta$, rank $d$}
|
||||
\For{$t=1$ \KwTo $T$}{
|
||||
Sample $u_t$ on unit sphere; set $x_t^\prime=x_t+\delta u_t$\;
|
||||
Set $p_t \gets U x_t^\prime$ and observe $q_t, R_t(p_t)$\;
|
||||
$x_{t+1} \gets \Pi\_{\mathcal{X}}(x_t-\eta R_t(p_t) u_t)$\;
|
||||
}
|
||||
\caption{Online Pricing Optimization (template)}
|
||||
\end{algorithm}
|
||||
16
paper/src/chapters/04-results.tex
Normal file
16
paper/src/chapters/04-results.tex
Normal file
@@ -0,0 +1,16 @@
|
||||
\section{Results}
|
||||
|
||||
\subsection{Behavioral Analysis}
|
||||
|
||||
Include markov chains of transition matrices, compare distributions (look at Divergence metrics)
|
||||
|
||||
|
||||
\subsection{Experimental Outcomes}
|
||||
|
||||
Align with defined objectives, show results and statistical significance (or not).
|
||||
|
||||
|
||||
\subsection{Interpretation and Insights}
|
||||
Inference from given patterns and show key findings.
|
||||
|
||||
\subsection{Anomalies}
|
||||
9
paper/src/chapters/05-discussion.tex
Normal file
9
paper/src/chapters/05-discussion.tex
Normal file
@@ -0,0 +1,9 @@
|
||||
\section{Discussion}
|
||||
|
||||
\subsection{Risk Assessment and Limitations}
|
||||
|
||||
Acknowledge risks and constraints and data sizes.
|
||||
|
||||
\subsection{Implications of Findings}
|
||||
|
||||
Interpretation of results and altenrative scenarios with broader market implications.
|
||||
8
paper/src/chapters/06-conclusion.tex
Normal file
8
paper/src/chapters/06-conclusion.tex
Normal file
@@ -0,0 +1,8 @@
|
||||
\section{Conclusion}
|
||||
|
||||
\subsection{Summary of contributions }
|
||||
Restate the thesis and key findings with validation of research objectives.
|
||||
|
||||
\subsection{Future Works and Next Steps}
|
||||
|
||||
Identify the research gaps here and potential business implications and setup of business + Proposed extensions and a long term agenda.
|
||||
3
paper/src/chapters/acknowledgements.tex
Normal file
3
paper/src/chapters/acknowledgements.tex
Normal file
@@ -0,0 +1,3 @@
|
||||
\section{Acknowledgements}
|
||||
|
||||
Eugene Bykovets, PhD - ETH
|
||||
@@ -35,6 +35,12 @@ The primary objective of this thesis is to develop and validate pricing heuristi
|
||||
\maketitle
|
||||
|
||||
\input{chapters/01-intro}
|
||||
\input{chapters/02-literature-review}
|
||||
\input{chapters/03-methodology}
|
||||
\input{chapters/04-results}
|
||||
\input{chapters/05-discussion}
|
||||
\input{chapters/06-conclusion}
|
||||
|
||||
|
||||
\printbibliography
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
\usepackage{csquotes}
|
||||
\usepackage{subcaption}
|
||||
\usepackage{siunitx}
|
||||
|
||||
\usepackage{tikz}
|
||||
\usepackage{listings}
|
||||
\usepackage{xcolor}
|
||||
\usepackage[ruled,vlined]{algorithm2e}
|
||||
|
||||
\usetikzlibrary{positioning, shapes, arrows.meta, fit, backgrounds}
|
||||
\lstset{
|
||||
basicstyle=\ttfamily\footnotesize,
|
||||
breaklines=true,
|
||||
@@ -18,7 +20,10 @@
|
||||
commentstyle=\color{green!60!black},
|
||||
stringstyle=\color{red},
|
||||
showstringspaces=false,
|
||||
captionpos=b
|
||||
captionpos=b,
|
||||
inputencoding=utf8,
|
||||
extendedchars=true,
|
||||
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
||||
}
|
||||
|
||||
% Use biblatex instead of natbib (acmart default)
|
||||
|
||||
7
pytest.ini
Normal file
7
pytest.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
[pytest]
|
||||
testpaths = experiments
|
||||
python_files = test*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -5,3 +5,9 @@ jupyter
|
||||
ipykernel
|
||||
matplotlib
|
||||
graphviz
|
||||
browser-use
|
||||
pytest
|
||||
pytest-asyncio
|
||||
uv
|
||||
scikit-learn
|
||||
supabase
|
||||
|
||||
101
web/README.md
101
web/README.md
@@ -1,36 +1,97 @@
|
||||
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).
|
||||
|
||||
## Getting Started
|
||||
# Phantom Air/Hotels
|
||||
|
||||
First, run the development server:
|
||||
Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Design-Discovery
|
||||
|
||||
```bash
|
||||
> 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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Server runs on `http://localhost:3000`
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## Environment Variables
|
||||
|
||||
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.
|
||||
| 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` |
|
||||
|
||||
## Learn More
|
||||
## Routes
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### 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
|
||||
|
||||
- [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.
|
||||
### 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
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## Event Catalog
|
||||
|
||||
## Deploy on Vercel
|
||||
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
|
||||
|
||||
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.
|
||||
| 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" } }` |
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## Architecture
|
||||
|
||||
### Route Groups
|
||||
- `(hotel)` — Hotel mode pages
|
||||
- `(airline)` — Airline mode pages
|
||||
- `api/*` — API routes (session, pricing, ingest, admin)
|
||||
|
||||
### Middleware Flow
|
||||
1. Request arrives at Next.js
|
||||
2. Session middleware checks for `phantom_session_id` cookie
|
||||
3. If missing, `/api/session` mints new session + sets cookie
|
||||
4. Store mode (`STORE_MODE` env) determines rendered page variant
|
||||
5. Client-side components fetch pricing via `/api/pricing`
|
||||
6. User interactions emit events to `/api/ingest` → Kafka
|
||||
|
||||
162
web/package-lock.json
generated
162
web/package-lock.json
generated
@@ -8,10 +8,12 @@
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"kafkajs": "^2.2.4",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -657,6 +659,97 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
|
||||
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
|
||||
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
|
||||
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
|
||||
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
|
||||
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.43.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
|
||||
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
|
||||
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.81.1",
|
||||
"@supabase/functions-js": "2.81.1",
|
||||
"@supabase/postgrest-js": "2.81.1",
|
||||
"@supabase/realtime-js": "2.81.1",
|
||||
"@supabase/storage-js": "2.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -941,12 +1034,17 @@
|
||||
"version": "20.19.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@@ -967,6 +1065,15 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
@@ -993,6 +1100,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -1041,15 +1157,6 @@
|
||||
"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",
|
||||
@@ -1614,8 +1721,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"kafkajs": "^2.2.4",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
185
web/src/app/admin/experiments/page.tsx
Executable file
185
web/src/app/admin/experiments/page.tsx
Executable file
@@ -0,0 +1,185 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
6
web/src/app/airline/layout.tsx
Normal file
6
web/src/app/airline/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/airline.css';
|
||||
|
||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="airline">{children}</div>;
|
||||
}
|
||||
9
web/src/app/airline/page.tsx
Normal file
9
web/src/app/airline/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||
|
||||
export default function AirlineHome() {
|
||||
return (
|
||||
<main>
|
||||
<AirlineHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
106
web/src/app/airline/products/[id]/page.tsx
Normal file
106
web/src/app/airline/products/[id]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import AirlineDetails from '@/components/feats/airline/AirlineDetails';
|
||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
export default function AirlineProductPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { addItem } = useCart();
|
||||
const [product, setProduct] = useState<Flight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const productId = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/products/${productId}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
||||
const json = await res.json();
|
||||
const transformed = transformProduct(json.data as AirlineProduct);
|
||||
setProduct(transformed);
|
||||
|
||||
// fire learn_more_about_item event when product loads
|
||||
dispatchInteraction('learn_more_about_item', productId, {
|
||||
type: 'airline',
|
||||
dateIndex: transformed.dateIndex,
|
||||
flightType: transformed.flightType,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load product');
|
||||
console.error('[FETCH_FLIGHT_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProduct();
|
||||
}, [productId]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return;
|
||||
|
||||
addItem({
|
||||
id: productId,
|
||||
type: 'airline',
|
||||
name: product.flightType,
|
||||
price: product.basePrice,
|
||||
metadata: {
|
||||
departure: product.departure,
|
||||
arrival: product.arrival,
|
||||
duration: product.duration,
|
||||
cabinClass: product.cabinClass,
|
||||
},
|
||||
dateIndex: product.dateIndex,
|
||||
});
|
||||
|
||||
dispatchInteraction('add_item_to_cart', productId, {
|
||||
type: 'airline',
|
||||
price: product.basePrice,
|
||||
});
|
||||
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{loading && <div className="text-center py-8">Loading flight details...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
|
||||
{!loading && !error && product && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to flights
|
||||
</button>
|
||||
<AirlineDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
web/src/app/airline/products/page.tsx
Normal file
70
web/src/app/airline/products/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
||||
|
||||
function FlightsList() {
|
||||
const searchParams = useSearchParams();
|
||||
const [flights, setFlights] = useState<Flight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFlights = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
url.searchParams.set('type', 'airline');
|
||||
|
||||
// forward all relevant search params to the API
|
||||
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
||||
params.forEach(param => {
|
||||
const val = searchParams.get(param);
|
||||
if (val) url.searchParams.set(param, val);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
||||
const json = await res.json();
|
||||
const transformed = json.data.map((p: AirlineProduct) => transformProduct(p));
|
||||
setFlights(transformed);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
console.error('[FETCH_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFlights();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||
{loading && <div className="text-center py-8">Loading...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-4">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AirlineProducts() {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||
<FlightsList />
|
||||
</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
web/src/app/api/admin/experiments/route.ts
Normal file
86
web/src/app/api/admin/experiments/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
58
web/src/app/api/admin/tasks/route.ts
Normal file
58
web/src/app/api/admin/tasks/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ tasks: data || [] });
|
||||
} catch (err: any) {
|
||||
console.error('tasks fetch error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
const body = await req.json();
|
||||
|
||||
const { task_name, task_description, task_def_of_done } = body;
|
||||
|
||||
if (!task_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'task_name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.insert([{ task_name, task_description, task_def_of_done }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ task: data });
|
||||
} catch (err: any) {
|
||||
console.error('task creation error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
web/src/app/api/ingest/route.ts
Normal file
42
web/src/app/api/ingest/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
69
web/src/app/api/pricing/route.ts
Normal file
69
web/src/app/api/pricing/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface PricingResponse {
|
||||
price: number;
|
||||
currency: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const productId = searchParams.get('productId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const experimentId = searchParams.get('experimentId');
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'productId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// stub: call external pricing provider (random for now)
|
||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||
const price = Math.round(basePrice * 100) / 100;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// log price to kafka for elasticity computation
|
||||
if (sessionId) {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
price,
|
||||
sessionId,
|
||||
experimentId: experimentId || undefined,
|
||||
storeMode,
|
||||
ts: timestamp,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[price-log-error]', err);
|
||||
// don't fail the pricing request if logging fails
|
||||
}
|
||||
}
|
||||
|
||||
// log in dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[pricing-api]', {
|
||||
productId,
|
||||
sessionId,
|
||||
experimentId,
|
||||
storeMode,
|
||||
price,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
const response: PricingResponse = {
|
||||
price,
|
||||
currency: 'EUR',
|
||||
cachedAt: timestamp,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
35
web/src/app/api/products/[id]/route.ts
Normal file
35
web/src/app/api/products/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'product id is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
const url = new URL(`${backendUrl}/api/products/${id}`);
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Backend returned ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('[PRODUCT_DETAIL_ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch product details' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
web/src/app/api/products/route.ts
Normal file
40
web/src/app/api/products/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (!type || !['hotel', 'airline'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'type parameter must be "hotel" or "airline"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
const url = new URL(`${backendUrl}/api/products/type/${type}`);
|
||||
|
||||
// forward all query params to backend (excluding 'type')
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'type') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Backend returned ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('[PRODUCTS_PROXY_ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch products' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
web/src/app/api/session/route.ts
Normal file
92
web/src/app/api/session/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
web/src/app/cart/page.tsx
Normal file
110
web/src/app/cart/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { Navigation } from '@/components/ui';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
const { items, removeItem, clearCart, itemCount } = useCart();
|
||||
|
||||
const handleRemove = (id: string, type: string) => {
|
||||
removeItem(id);
|
||||
dispatchInteraction('remove_item', id, { type });
|
||||
};
|
||||
let itemTypes = Array.from(new Set(items.map(item => item.type)))[0] || 'items';
|
||||
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.price, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
||||
{itemCount > 0 && (
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Clear cart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{itemCount === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
||||
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4 mb-8">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex justify-between items-start p-4 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
{item.type}
|
||||
</span>
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
</div>
|
||||
|
||||
{item.type === 'hotel' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{String(item.metadata.roomType)}</p>
|
||||
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
||||
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === 'airline' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{String(item.metadata.cabinClass)} Class</p>
|
||||
<p>{String((item.metadata.departure as any)?.airport)} → {String((item.metadata.arrival as any)?.airport)}</p>
|
||||
<p>Duration: {String(item.metadata.duration)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p className="text-xl font-bold mb-2">${item.price}</p>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id, item.type)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-xl font-semibold">Total</span>
|
||||
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
@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 {
|
||||
@@ -12,6 +23,7 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
@@ -19,8 +31,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
6
web/src/app/hotel/layout.tsx
Normal file
6
web/src/app/hotel/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/hotel.css';
|
||||
|
||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="hotel">{children}</div>;
|
||||
}
|
||||
9
web/src/app/hotel/page.tsx
Normal file
9
web/src/app/hotel/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||
|
||||
export default function HotelHome() {
|
||||
return (
|
||||
<main>
|
||||
<HotelHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
106
web/src/app/hotel/products/[id]/page.tsx
Normal file
106
web/src/app/hotel/products/[id]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import HotelDetails from '@/components/feats/hotel/HotelDetails';
|
||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
export default function HotelProductPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { addItem } = useCart();
|
||||
const [product, setProduct] = useState<Hotel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const productId = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/products/${productId}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
||||
const json = await res.json();
|
||||
const transformed = transformProduct(json.data as HotelProduct);
|
||||
setProduct(transformed);
|
||||
|
||||
// fire learn_more_about_item event when product loads
|
||||
dispatchInteraction('learn_more_about_item', productId, {
|
||||
type: 'hotel',
|
||||
dateIndex: transformed.dateIndex,
|
||||
roomType: transformed.roomType,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load product');
|
||||
console.error('[FETCH_HOTEL_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProduct();
|
||||
}, [productId]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return;
|
||||
|
||||
addItem({
|
||||
id: productId,
|
||||
type: 'hotel',
|
||||
name: product.name,
|
||||
price: product.pricePerNight,
|
||||
metadata: {
|
||||
roomType: product.roomType,
|
||||
nights: product.nights,
|
||||
checkIn: product.checkIn,
|
||||
checkOut: product.checkOut,
|
||||
},
|
||||
dateIndex: product.dateIndex,
|
||||
});
|
||||
|
||||
dispatchInteraction('add_item_to_cart', productId, {
|
||||
type: 'hotel',
|
||||
price: product.pricePerNight,
|
||||
});
|
||||
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{loading && <div className="text-center py-8">Loading hotel details...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
|
||||
{!loading && !error && product && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mt-6 text-blue-600 hover:underline"
|
||||
>
|
||||
← Back to rooms
|
||||
</button>
|
||||
<HotelDetails
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
addedToCart={added}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
web/src/app/hotel/products/page.tsx
Normal file
70
web/src/app/hotel/products/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
||||
|
||||
function RoomsList() {
|
||||
const searchParams = useSearchParams();
|
||||
const [rooms, setRooms] = useState<Hotel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
url.searchParams.set('type', 'hotel');
|
||||
|
||||
// forward all relevant search params to the API
|
||||
const params = ['dateIndex', 'destination', 'adults', 'rooms'];
|
||||
params.forEach(param => {
|
||||
const val = searchParams.get(param);
|
||||
if (val) url.searchParams.set(param, val);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
||||
const json = await res.json();
|
||||
const transformed = json.data.map((p: HotelProduct) => transformProduct(p));
|
||||
setRooms(transformed);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
console.error('[FETCH_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRooms();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-6">Available Rooms</h1>
|
||||
{loading && <div className="text-center py-8">Loading...</div>}
|
||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-4">
|
||||
{rooms.map((r) => (
|
||||
<HotelCard key={r.id} hotel={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HotelProducts() {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||
<RoomsList />
|
||||
</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { TrackingProvider } from "@/components/TrackingProvider";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -28,7 +29,9 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<CartProvider>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
93
web/src/app/start-task/page.tsx
Normal file
93
web/src/app/start-task/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
const StartTaskContent = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'redirecting'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const uuid = searchParams.get('uuid');
|
||||
|
||||
if (!uuid) {
|
||||
setError('no experiment UUID provided');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAndStore = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/experiments?id=${uuid}`);
|
||||
if (!res.ok) throw new Error('experiment not found');
|
||||
|
||||
const data = await res.json();
|
||||
const exp = data.experiment;
|
||||
|
||||
if (!exp) throw new Error('invalid experiment UUID');
|
||||
|
||||
localStorage.setItem('phantom_experiment_id', uuid);
|
||||
|
||||
await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ experimentId: uuid }),
|
||||
});
|
||||
|
||||
setStatus('redirecting');
|
||||
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 800);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'failed to start task');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
validateAndStore();
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="text-center">
|
||||
{status === 'loading' && (
|
||||
<div>
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-200 border-t-zinc-900 dark:border-zinc-800 dark:border-t-zinc-100 mx-auto" />
|
||||
<p className="text-zinc-600 dark:text-zinc-400">validating browser...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'redirecting' && (
|
||||
<div>
|
||||
<div className="mb-4 text-4xl">✓</div>
|
||||
<p className="text-zinc-900 dark:text-zinc-100 font-medium">website loaded</p>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">redirecting to page...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-lg bg-red-50 p-6 dark:bg-red-950">
|
||||
<p className="text-red-900 dark:text-red-100 font-medium">error</p>
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StartTaskPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<StartTaskContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
118
web/src/components/admin/ExperimentForm.tsx
Normal file
118
web/src/components/admin/ExperimentForm.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type ExperimentFormProps = {
|
||||
selectedTaskId?: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const ExperimentForm = ({ selectedTaskId, onSuccess }: ExperimentFormProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
subject_name: '',
|
||||
xp_human_only: false,
|
||||
xp_market_mode: 'hotel' as 'hotel' | 'airline',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
xp_task_id: selectedTaskId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'creation failed');
|
||||
}
|
||||
|
||||
setForm({ subject_name: '', xp_human_only: false, xp_market_mode: 'hotel' });
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Create Experiment
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
subject name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subject_name}
|
||||
onChange={(e) => setForm({ ...form, subject_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="e.g., baseline_dynamic_pricing_v1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
market mode
|
||||
</label>
|
||||
<select
|
||||
value={form.xp_market_mode}
|
||||
onChange={(e) => setForm({ ...form, xp_market_mode: e.target.value as 'hotel' | 'airline' })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
>
|
||||
<option value="hotel">hotel</option>
|
||||
<option value="airline">airline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="human-only"
|
||||
checked={form.xp_human_only}
|
||||
onChange={(e) => setForm({ ...form, xp_human_only: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
/>
|
||||
<label htmlFor="human-only" className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
human participants only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedTaskId && (
|
||||
<div className="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
task selected: <span className="font-mono text-xs">{selectedTaskId.slice(0, 8)}...</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'creating experiment...' : 'create experiment'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
178
web/src/components/admin/TaskManager.tsx
Normal file
178
web/src/components/admin/TaskManager.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
task_name: string;
|
||||
task_description: string;
|
||||
task_def_of_done: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type TaskManagerProps = {
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
selectedTaskId?: string;
|
||||
};
|
||||
|
||||
export const TaskManager = ({ onTaskSelect, selectedTaskId }: TaskManagerProps) => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
task_name: '',
|
||||
task_description: '',
|
||||
task_def_of_done: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/tasks');
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'creation failed');
|
||||
}
|
||||
|
||||
setForm({ task_name: '', task_description: '', task_def_of_done: '' });
|
||||
setShowForm(false);
|
||||
await fetchTasks();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Tasks
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-black dark:hover:bg-zinc-300"
|
||||
>
|
||||
{showForm ? 'cancel' : 'new task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
task name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.task_name}
|
||||
onChange={(e) => setForm({ ...form, task_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="e.g., Book cheapest flight to Paris"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.task_description}
|
||||
onChange={(e) => setForm({ ...form, task_description: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="User should find and book the cheapest available flight..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
definition of done
|
||||
</label>
|
||||
<textarea
|
||||
value={form.task_def_of_done}
|
||||
onChange={(e) => setForm({ ...form, task_def_of_done: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="Booking is completed and confirmation page is shown"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'creating...' : 'create task'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
no tasks yet
|
||||
</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => onTaskSelect?.(task.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selectedTaskId === task.id
|
||||
? 'border-zinc-900 bg-zinc-50 dark:border-zinc-100 dark:bg-zinc-900'
|
||||
: 'border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{task.task_name}
|
||||
</h3>
|
||||
{task.task_description && (
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{task.task_description}
|
||||
</p>
|
||||
)}
|
||||
{task.task_def_of_done && (
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
|
||||
done: {task.task_def_of_done}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
web/src/components/feats/airline/AirlineCard.tsx
Normal file
75
web/src/components/feats/airline/AirlineCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Flight } from '@/lib/airline-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
const durationRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('view_item_page', flight.id, {
|
||||
cabinClass: flight.cabinClass,
|
||||
fareRule: flight.fareRule,
|
||||
price: flight.basePrice,
|
||||
dateIndex: flight.dateIndex,
|
||||
});
|
||||
window.location.href = `/airline/products/${flight.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flight-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.departure.time}</div>
|
||||
<div className="flight-airport">{flight.departure.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-route">
|
||||
<div ref={durationRef} className="flight-duration">{flight.duration}</div>
|
||||
<div className="flight-stops">
|
||||
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.arrival.time}</div>
|
||||
<div className="flight-airport">{flight.arrival.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-pricing">
|
||||
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
|
||||
{flight.refundable && (
|
||||
<div className="badge-value text-xs mb-2">Refundable</div>
|
||||
)}
|
||||
<div ref={priceRef}>
|
||||
<PriceDisplay
|
||||
productId={flight.id}
|
||||
className="fare-price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import type { Flight } from '@/lib/airline-utils';
|
||||
|
||||
interface AirlineDetailsProps {
|
||||
product: Flight;
|
||||
onAddToCart: () => void;
|
||||
addedToCart: boolean;
|
||||
}
|
||||
|
||||
export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) {
|
||||
return (
|
||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section */}
|
||||
<div className="w-full lg:w-1/3 bg-gray-100 rounded-lg aspect-square flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Flight Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start border-b pb-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-1">{product.flightType}</h1>
|
||||
<p className="text-lg text-gray-500">{product.cabinClass} Class</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-4xl font-bold text-gray-900">${product.basePrice}</p>
|
||||
{product.refundable && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-xs font-medium">
|
||||
Refundable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.departure.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.departure.airport}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-8 flex flex-col items-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{product.duration}</p>
|
||||
<div className="w-full h-0.5 bg-gray-200 relative flex items-center justify-center">
|
||||
<div className="absolute w-3 h-3 bg-gray-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.arrival.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.arrival.airport}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-6 border-t">
|
||||
<div className="text-gray-600">
|
||||
<span className="font-bold text-gray-900">{product.availability}</span> seats remaining
|
||||
<span className="mx-2">•</span>
|
||||
{product.fareRule}
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
web/src/components/feats/airline/AirlineHero.tsx
Normal file
175
web/src/components/feats/airline/AirlineHero.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||
|
||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||
|
||||
const PlaneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LocationIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AirlineHero() {
|
||||
const router = useRouter();
|
||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [departDate, setDepartDate] = useState('');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (departDate) {
|
||||
const daysOffset = dateToDaysFromToday(departDate);
|
||||
params.set('dateIndex', daysOffset.toString());
|
||||
}
|
||||
|
||||
if (origin) params.set('origin', origin);
|
||||
if (destination) params.set('destination', destination);
|
||||
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
||||
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
||||
|
||||
params.set('adults', passengers.adults.toString());
|
||||
params.set('children', passengers.children.toString());
|
||||
params.set('infants', passengers.infants.toString());
|
||||
|
||||
router.push(`/airline/products?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||
|
||||
return (
|
||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-5xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Book flights at the best prices
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Compare hundreds of airlines and find the perfect flight for your journey
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="search-form">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="mb-6">
|
||||
<RadioGroup
|
||||
name="tripType"
|
||||
value={tripType}
|
||||
onChange={setTripType}
|
||||
options={[
|
||||
{ value: 'roundtrip', label: 'Round-trip' },
|
||||
{ value: 'oneway', label: 'One-way' },
|
||||
{ value: 'multicity', label: 'Multi-city' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="origin">From</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="origin"
|
||||
value={origin}
|
||||
onChange={(e) => setOrigin(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<PlaneIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="destination">To</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="destination"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<LocationIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="departDate">Departure</Label>
|
||||
<DateInput
|
||||
id="departDate"
|
||||
value={departDate}
|
||||
onChange={(e) => setDepartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="returnDate">Return</Label>
|
||||
{tripType === 'roundtrip' ? (
|
||||
<DateInput
|
||||
id="returnDate"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<DateInput id="returnDate" disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||
<div className="sm:col-span-1 lg:col-span-1">
|
||||
<Label htmlFor="passengers">Passengers</Label>
|
||||
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
sublabel="12+ years"
|
||||
value={passengers.adults}
|
||||
min={1}
|
||||
onChange={(v) => setPassengers({ ...passengers, adults: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Children"
|
||||
sublabel="2-11 years"
|
||||
value={passengers.children}
|
||||
onChange={(v) => setPassengers({ ...passengers, children: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Infants"
|
||||
sublabel="Under 2"
|
||||
value={passengers.infants}
|
||||
onChange={(v) => setPassengers({ ...passengers, infants: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" fullWidth>
|
||||
Search Flights
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
web/src/components/feats/hotel/HotelCard.tsx
Normal file
89
web/src/components/feats/hotel/HotelCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
const AmenityIcon = ({ name }: { name: string }) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
wifi: 'Wi-Fi',
|
||||
pool: 'Pool',
|
||||
gym: 'Gym',
|
||||
parking: 'Parking',
|
||||
breakfast: 'Breakfast',
|
||||
spa: 'Spa',
|
||||
};
|
||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
||||
};
|
||||
|
||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
const titleRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('view_item_page', hotel.id, {
|
||||
roomType: hotel.roomType,
|
||||
price: hotel.pricePerNight,
|
||||
nights: hotel.nights,
|
||||
dateIndex: hotel.dateIndex,
|
||||
});
|
||||
window.location.href = `/hotel/products/${hotel.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hotel-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Image</span>
|
||||
</div>
|
||||
|
||||
<div className="hotel-info">
|
||||
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{hotel.checkIn} - {hotel.checkOut}
|
||||
</div>
|
||||
<div className="hotel-features">
|
||||
{hotel.amenities.map((a) => (
|
||||
<AmenityIcon key={a} name={a} />
|
||||
))}
|
||||
</div>
|
||||
{hotel.refundable && (
|
||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hotel-pricing">
|
||||
<div ref={priceRef}>
|
||||
<PriceDisplay
|
||||
productId={hotel.id}
|
||||
className="price-wrapper"
|
||||
perNight
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
|
||||
interface HotelDetailsProps {
|
||||
product: Hotel;
|
||||
onAddToCart: () => void;
|
||||
addedToCart: boolean;
|
||||
}
|
||||
|
||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||
return (
|
||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section - Larger and cleaner */}
|
||||
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section - Full height/width usage */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b pb-6 mb-6">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
||||
<p className="text-xl text-gray-500">{product.roomType}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-in</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkIn}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-out</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkOut}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.amenities.map(a => (
|
||||
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.refundable && (
|
||||
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
|
||||
<span className="font-medium">Free cancellation available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
web/src/components/feats/hotel/HotelHero.tsx
Normal file
100
web/src/components/feats/hotel/HotelHero.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
||||
|
||||
const LocationIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function HotelHero() {
|
||||
const router = useRouter();
|
||||
const [destination, setDestination] = useState('');
|
||||
const [checkIn, setCheckIn] = useState('');
|
||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (checkIn) {
|
||||
const daysOffset = dateToDaysFromToday(checkIn);
|
||||
params.set('dateIndex', daysOffset.toString());
|
||||
}
|
||||
|
||||
if (destination) params.set('destination', destination);
|
||||
params.set('adults', guests.adults.toString());
|
||||
params.set('rooms', guests.rooms.toString());
|
||||
|
||||
router.push(`/hotel/products?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Find your perfect room
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Search rooms, compare prices, and book with confidence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="destination">Where to?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="destination"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="City, hotel, or landmark"
|
||||
icon={<LocationIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
||||
<DateInput
|
||||
id="checkIn"
|
||||
value={checkIn}
|
||||
onChange={(e) => setCheckIn(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="guests">Guests</Label>
|
||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
value={guests.adults}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<Button type="submit" fullWidth>
|
||||
Search Rooms
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
web/src/components/ui/Button.tsx
Normal file
20
web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
7
web/src/components/ui/DateInput.tsx
Normal file
7
web/src/components/ui/DateInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
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} />;
|
||||
}
|
||||
83
web/src/components/ui/Dropdown.tsx
Normal file
83
web/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
29
web/src/components/ui/Input.tsx
Normal file
29
web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
web/src/components/ui/Label.tsx
Normal file
13
web/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
web/src/components/ui/Navigation.tsx
Normal file
48
web/src/components/ui/Navigation.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
136
web/src/components/ui/PriceDisplay.tsx
Normal file
136
web/src/components/ui/PriceDisplay.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
33
web/src/components/ui/RadioGroup.tsx
Normal file
33
web/src/components/ui/RadioGroup.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
7
web/src/components/ui/index.ts
Normal file
7
web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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';
|
||||
76
web/src/contexts/CartContext.tsx
Normal file
76
web/src/contexts/CartContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
type: 'hotel' | 'airline';
|
||||
name: string;
|
||||
price: number;
|
||||
metadata: Record<string, unknown>;
|
||||
dateIndex: number;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
const CART_KEY = 'phantom_cart';
|
||||
|
||||
export const CartProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// load cart from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(CART_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setItems(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
console.error('[CART_LOAD]', e);
|
||||
}
|
||||
}
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
// persist to sessionStorage whenever cart changes
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
sessionStorage.setItem(CART_KEY, JSON.stringify(items));
|
||||
}, [items, loaded]);
|
||||
|
||||
const addItem = (item: CartItem) => {
|
||||
setItems(prev => {
|
||||
// prevent duplicates
|
||||
if (prev.find(i => i.id === item.id)) return prev;
|
||||
return [...prev, item];
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems(prev => prev.filter(i => i.id !== id));
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setItems([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ items, addItem, removeItem, clearCart, itemCount: items.length }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCart = () => {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
||||
return ctx;
|
||||
};
|
||||
63
web/src/hooks/useHoverTracking.ts
Normal file
63
web/src/hooks/useHoverTracking.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (
|
||||
eventName: EventName,
|
||||
productId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
interface UseHoverTrackingOptions {
|
||||
eventName: EventName;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
|
||||
}
|
||||
|
||||
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
|
||||
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
|
||||
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
|
||||
: 1500;
|
||||
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
|
||||
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const startRef = useRef<number | undefined>(undefined);
|
||||
|
||||
return useCallback((node: HTMLElement | null) => {
|
||||
if (!node) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
startRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
const dwellTime = Date.now() - startRef.current!;
|
||||
dispatchInteraction(eventName, productId, {
|
||||
...metadata,
|
||||
dwellTime,
|
||||
});
|
||||
}, threshold);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener('mouseenter', onEnter);
|
||||
node.addEventListener('mouseleave', onLeave);
|
||||
|
||||
return () => {
|
||||
node.removeEventListener('mouseenter', onEnter);
|
||||
node.removeEventListener('mouseleave', onLeave);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [eventName, productId, metadata, threshold]);
|
||||
};
|
||||
@@ -1,31 +1,34 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import '@/lib/experiments' // ensure experiments lib is loaded
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import '@/lib/experiments'
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
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.
|
||||
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 '';
|
||||
}
|
||||
return sid;
|
||||
};
|
||||
|
||||
const track = async (ev: {
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
targetEl?: string;
|
||||
targetUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
eventName: EventName;
|
||||
page: string;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => {
|
||||
try {
|
||||
await fetch('/api/track', {
|
||||
const experimentId = localStorage.getItem('phantom_experiment_id');
|
||||
await fetch('/api/ingest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ev),
|
||||
body: JSON.stringify({
|
||||
...ev,
|
||||
experimentId: experimentId || undefined,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('track failed:', err);
|
||||
@@ -34,84 +37,54 @@ const track = async (ev: {
|
||||
|
||||
export const useInteractionTracking = () => {
|
||||
const sidRef = useRef<string>('');
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
sidRef.current = genSessionId();
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement;
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: 'click',
|
||||
targetEl: tgt.tagName,
|
||||
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
|
||||
metadata: {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
path: window.location.pathname,
|
||||
},
|
||||
// fetch session id from httpOnly cookie via API
|
||||
fetchSessionId().then((sid) => {
|
||||
sidRef.current = sid;
|
||||
setReady(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: 'scroll',
|
||||
metadata: {
|
||||
scrollY: window.scrollY,
|
||||
path: window.location.pathname,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageView = () => {
|
||||
if (!sidRef.current) return;
|
||||
const page = window.location.pathname;
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: 'pageview',
|
||||
eventName: 'page_view',
|
||||
page,
|
||||
metadata: {
|
||||
path: window.location.pathname,
|
||||
referrer: document.referrer,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
) => {
|
||||
// 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;
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: interactionType,
|
||||
metadata: {
|
||||
path: window.location.pathname,
|
||||
...metadata,
|
||||
},
|
||||
eventName: customEvent.detail.eventName,
|
||||
page,
|
||||
productId: customEvent.detail.productId,
|
||||
metadata: customEvent.detail.metadata,
|
||||
});
|
||||
};
|
||||
|
||||
// wait for session to be ready before tracking
|
||||
if (!ready) return;
|
||||
|
||||
handlePageView();
|
||||
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 });
|
||||
document.addEventListener('definedInteraction', handleDefinedInteraction);
|
||||
|
||||
return () => {
|
||||
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);
|
||||
document.removeEventListener('definedInteraction', handleDefinedInteraction);
|
||||
};
|
||||
}, []);
|
||||
}, [ready]);
|
||||
};
|
||||
|
||||
38
web/src/hooks/useSession.ts
Normal file
38
web/src/hooks/useSession.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
};
|
||||
75
web/src/lib/airline-utils.ts
Normal file
75
web/src/lib/airline-utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface AirlineProduct {
|
||||
id: string;
|
||||
flight_type: string;
|
||||
date_index: number;
|
||||
metadata: {
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabin_class: string;
|
||||
fare_rule: string;
|
||||
refundable: boolean;
|
||||
total?: number;
|
||||
base_price: number;
|
||||
};
|
||||
availability: number;
|
||||
}
|
||||
|
||||
export interface Flight {
|
||||
id: string;
|
||||
flightType: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: string;
|
||||
fareRule: string;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
dateIndex: number;
|
||||
availability: number;
|
||||
}
|
||||
|
||||
const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: AirlineProduct): Flight => {
|
||||
const { id, flight_type, date_index, metadata, availability } = p;
|
||||
|
||||
return {
|
||||
id,
|
||||
flightType: flight_type,
|
||||
departure: metadata.departure,
|
||||
arrival: metadata.arrival,
|
||||
duration: metadata.duration,
|
||||
stops: metadata.stops,
|
||||
cabinClass: metadata.cabin_class,
|
||||
fareRule: metadata.fare_rule,
|
||||
refundable: metadata.refundable,
|
||||
basePrice: metadata.base_price,
|
||||
dateIndex: date_index,
|
||||
availability,
|
||||
};
|
||||
};
|
||||
|
||||
// convert date string to days from today
|
||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// convert date string to date_index (days since epoch)
|
||||
export const dateToIndex = (dateStr: string): number => {
|
||||
const d = new Date(dateStr);
|
||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// get current date_index
|
||||
export const todayIndex = (): number => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
30
web/src/lib/config.ts
Normal file
30
web/src/lib/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
91
web/src/lib/events.ts
Normal file
91
web/src/lib/events.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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>;
|
||||
71
web/src/lib/hotel-utils.ts
Normal file
71
web/src/lib/hotel-utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface HotelProduct {
|
||||
id: string;
|
||||
room_type: string;
|
||||
date_index: number;
|
||||
metadata: {
|
||||
amenities?: string[];
|
||||
total?: number;
|
||||
image_url?: string;
|
||||
base_price?: number;
|
||||
name?: string;
|
||||
refundable?: boolean;
|
||||
};
|
||||
availability: number;
|
||||
}
|
||||
|
||||
export interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
dateIndex: number;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
||||
const { id, room_type, date_index, metadata } = p;
|
||||
const checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
||||
const nights = 1;
|
||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: metadata?.name || room_type,
|
||||
roomType: room_type,
|
||||
checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
dateIndex: date_index,
|
||||
amenities: metadata?.amenities || [],
|
||||
refundable: metadata?.refundable || false,
|
||||
pricePerNight: metadata?.base_price || 100,
|
||||
nights,
|
||||
};
|
||||
};
|
||||
|
||||
// convert date string to days from today
|
||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// convert date string to date_index (days since epoch)
|
||||
export const dateToIndex = (dateStr: string): number => {
|
||||
const d = new Date(dateStr);
|
||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// get current date_index
|
||||
export const todayIndex = (): number => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
25
web/src/lib/product-utils.ts
Normal file
25
web/src/lib/product-utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { HotelProduct, Hotel, transformProduct as transformHotel } from './hotel-utils';
|
||||
import { AirlineProduct, Flight, transformProduct as transformFlight } from './airline-utils';
|
||||
|
||||
export type Product = Hotel | Flight;
|
||||
export type ProductRaw = HotelProduct | AirlineProduct;
|
||||
|
||||
export const isHotelProduct = (p: ProductRaw): p is HotelProduct => {
|
||||
return 'room_type' in p;
|
||||
};
|
||||
|
||||
export const isAirlineProduct = (p: ProductRaw): p is AirlineProduct => {
|
||||
return 'flight_type' in p;
|
||||
};
|
||||
|
||||
export const transformProduct = (p: ProductRaw): Product => {
|
||||
if (isHotelProduct(p)) {
|
||||
return transformHotel(p);
|
||||
}
|
||||
return transformFlight(p);
|
||||
};
|
||||
|
||||
export const getProductType = (p: Product): 'hotel' | 'airline' => {
|
||||
if ('roomType' in p) return 'hotel';
|
||||
return 'airline';
|
||||
};
|
||||
102
web/src/lib/sessionStore.ts
Normal file
102
web/src/lib/sessionStore.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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());
|
||||
38
web/src/proxy.ts
Normal file
38
web/src/proxy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export function proxy(req: NextRequest) {
|
||||
const mode = process.env.STORE_MODE;
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// skip rewrites for api routes, admin routes, static files, and next internals
|
||||
if (
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/admin') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/static') ||
|
||||
pathname.startsWith('/start-task') ||
|
||||
pathname.startsWith('/cart') ||
|
||||
pathname.includes('.')
|
||||
// TODO: add robots.txt and sitemap.xml if needed here
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// already prefixed with mode
|
||||
if (pathname.startsWith(`/${mode}`)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// rewrite root and unprefixed paths to mode-specific route group
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`;
|
||||
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// match all paths except those starting with _next/static, _next/image, favicon.ico
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
321
web/src/styles/airline.css
Normal file
321
web/src/styles/airline.css
Normal file
@@ -0,0 +1,321 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
418
web/src/styles/hotel.css
Normal file
418
web/src/styles/hotel.css
Normal file
@@ -0,0 +1,418 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
10
web/src/utils/supabase/client.ts
Normal file
10
web/src/utils/supabase/client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
export const createClient = () =>
|
||||
createBrowserClient(
|
||||
supabaseUrl!,
|
||||
supabaseKey!,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user