mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
1 Commits
2-nextjs-s
...
claude/set
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cdcd9ea15 |
21
.env.example
21
.env.example
@@ -1,18 +1,5 @@
|
||||
# Network configuration
|
||||
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||
HOSTNAME=localhost
|
||||
|
||||
# Application configuration
|
||||
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
|
||||
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
|
||||
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
|
||||
|
||||
# Backend service
|
||||
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
|
||||
|
||||
# Service ports - used by docker-compose and service communication
|
||||
BACKEND_PORT=5000 # backend server port for kafka ingestion API
|
||||
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
|
||||
KAFKA_PORT=9092 # kafka broker port for event streaming
|
||||
REDIS_PORT=6377 # redis port for worker queue and caching
|
||||
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
|
||||
# PORTS
|
||||
KAFKA_PORT=9092
|
||||
REDIS_PORT=6377
|
||||
|
||||
15
.github/workflows/latex.yml
vendored
15
.github/workflows/latex.yml
vendored
@@ -26,3 +26,18 @@ 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)
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,2 @@
|
||||
**/.env
|
||||
**/.venv
|
||||
PHANTOM.wiki/
|
||||
**/.virtual_documents/
|
||||
**/__pycache__/
|
||||
**/.ipynb_checkpoints/
|
||||
190
README.md
190
README.md
@@ -1,5 +1,191 @@
|
||||
# PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms
|
||||
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
|
||||
- https://phantom-hotel.vercel.app/
|
||||
- https://phantom-airline.vercel.app/
|
||||
> Bachelor's Thesis Project by Daniel Rösel, IE University Madrid (2025)
|
||||
> Advisor: Alberto Martín Izquierdo
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# boilerplate code
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
import uvicorn
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from kafka import KafkaProducer
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# kafka producer - lazy init
|
||||
_producer: Optional[KafkaProducer] = None
|
||||
|
||||
def get_producer() -> KafkaProducer:
|
||||
global _producer
|
||||
if _producer is None:
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}' if port else host
|
||||
_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,
|
||||
)
|
||||
return _producer
|
||||
|
||||
class EventPayload(BaseModel):
|
||||
sessionId: str
|
||||
eventName: str
|
||||
page: str
|
||||
productId: Optional[str] = None
|
||||
metadata: Optional[dict[str, Any]] = None
|
||||
storeMode: str
|
||||
userAgent: Optional[str] = None
|
||||
ts: Optional[str] = None
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.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()
|
||||
producer.send(
|
||||
'user-interactions',
|
||||
key=event.sessionId,
|
||||
value=event.model_dump()
|
||||
)
|
||||
producer.flush(timeout=5)
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/kafka/dump")
|
||||
def dump_logs():
|
||||
# TODO: implement a dump of logs of time period t_start to t_end (params of get)
|
||||
# OR: allow for params of last_n logs as a param - creating two modes of the dumping
|
||||
pass
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||
@@ -1,5 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
kafka-python==2.0.2
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
@@ -1,34 +1,15 @@
|
||||
services:
|
||||
backend:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
environment:
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
depends_on:
|
||||
- kafka
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "PHANTOM-redis"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Redis.dockerfile
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${REDIS_PORT:-6378}:6379"
|
||||
volumes:
|
||||
- phantom_redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
zookeeper:
|
||||
container_name: "PHANTOM-zookeeper"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Zookeeper.dockerfile
|
||||
image: confluentinc/cp-zookeeper:latest
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
@@ -36,9 +17,7 @@ services:
|
||||
|
||||
kafka:
|
||||
container_name: "PHANTOM-kafka"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Kafka.dockerfile
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
depends_on:
|
||||
- zookeeper
|
||||
environment:
|
||||
@@ -57,9 +36,7 @@ services:
|
||||
|
||||
redpanda-console:
|
||||
container_name: "PHANTOM-redpanda-console"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: RedpandaConsole.dockerfile
|
||||
image: docker.redpanda.com/redpandadata/console:latest
|
||||
depends_on:
|
||||
- kafka
|
||||
environment:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM confluentinc/cp-kafka:7.5.0
|
||||
|
||||
# Expose Kafka ports
|
||||
# 9092: External client connections
|
||||
# 29092: Internal broker communication
|
||||
# 9999: JMX monitoring port
|
||||
EXPOSE 9092 29092 9999
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
# Expose Redis port
|
||||
EXPOSE 6379
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM docker.redpanda.com/redpandadata/console:latest
|
||||
|
||||
# Expose Redpanda Console web UI port
|
||||
EXPOSE 8080
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM confluentinc/cp-zookeeper:latest
|
||||
|
||||
# Expose Zookeeper client port
|
||||
EXPOSE 2181
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/server/app.py .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
@@ -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="TODO">
|
||||
<meta name="citation_pdf_url" content="static/pdfs/thesis.pdf">
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
@@ -238,14 +238,14 @@
|
||||
|
||||
<div class="column has-text-centered">
|
||||
<div class="publication-links">
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<!-- Thesis PDF - automatically updated via GitHub Actions -->
|
||||
<span class="link-block">
|
||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
||||
<a href="static/pdfs/thesis.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>Paper</span>
|
||||
<span>Thesis PDF</span>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -270,8 +270,8 @@
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<!-- TODO: Update with your arXiv paper ID when available -->
|
||||
<!-- <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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,19 +6,14 @@
|
||||
(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" "natbib=false")))
|
||||
'(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm")))
|
||||
(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,11 +6,5 @@
|
||||
%% \label{fig:example}
|
||||
%% \end{figure}
|
||||
|
||||
\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.
|
||||
\section{Know They Enemy}
|
||||
To know how to overcome we need to
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
\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)
|
||||
@@ -1,68 +0,0 @@
|
||||
\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}
|
||||
@@ -1,16 +0,0 @@
|
||||
\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}
|
||||
@@ -1,9 +0,0 @@
|
||||
\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.
|
||||
@@ -1,8 +0,0 @@
|
||||
\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.
|
||||
@@ -1,3 +0,0 @@
|
||||
\section{Acknowledgements}
|
||||
|
||||
Eugene Bykovets, PhD - ETH
|
||||
@@ -35,12 +35,6 @@ 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,12 +4,10 @@
|
||||
\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,
|
||||
@@ -20,10 +18,7 @@
|
||||
commentstyle=\color{green!60!black},
|
||||
stringstyle=\color{red},
|
||||
showstringspaces=false,
|
||||
captionpos=b,
|
||||
inputencoding=utf8,
|
||||
extendedchars=true,
|
||||
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
||||
captionpos=b
|
||||
}
|
||||
|
||||
% Use biblatex instead of natbib (acmart default)
|
||||
|
||||
101
web/README.md
101
web/README.md
@@ -1,97 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
# Phantom Air/Hotels
|
||||
## Getting Started
|
||||
|
||||
Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Design-Discovery
|
||||
First, run the development server:
|
||||
|
||||
> This webapp serves two modes `{HOTEL,AIRLINE}` which are given by an env variable
|
||||
|
||||
The webapp should serve under the / route the landing page which for both platforms is very similar. We define a set of components like Hero, Card, Button, Link ... This we can then pass to specific components each mode might demand that makes it behave differently, hotel cards showing hotel rooms from database and airline cards showing flights from database and each fetching prices from the pricing provider with a different HTTP parameter.
|
||||
|
||||
- globally we define a middleware.ts which is our switcher for modes.
|
||||
- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition.
|
||||
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
|
||||
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
|
||||
|
||||
## How to Run
|
||||
|
||||
```sh
|
||||
# install deps
|
||||
npm install
|
||||
|
||||
# set store mode (hotel or airline)
|
||||
export STORE_MODE=hotel
|
||||
|
||||
# run dev server
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Server runs on `http://localhost:3000`
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## Environment Variables
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `HOSTNAME` | Server hostname | `localhost` | `localhost` |
|
||||
| `STORE_MODE` | Mode switch for platform | `hotel` | `hotel` or `airline` |
|
||||
| `NEXT_PUBLIC_API_BASE` | Public API base URL | `http://localhost:3000` | `http://localhost:3000` |
|
||||
| `NEXT_PUBLIC_APP_ENV` | Application environment | `dev` | `dev`, `prod` |
|
||||
| `NEXT_PUBLIC_HOVER_THRESHOLD` | Hover dwell threshold (ms) | `1200` | `1200` |
|
||||
| `BACKEND_URL` | Backend service URL | `http://localhost:5000` | `http://localhost:5000` |
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Routes
|
||||
## Learn More
|
||||
|
||||
### Public Pages
|
||||
- `/` — Landing page (mode-aware root)
|
||||
- `/hotel` — Hotel mode landing
|
||||
- `/hotel/products` — Hotel catalog
|
||||
- `/airline` — Airline mode landing
|
||||
- `/airline/products` — Flight catalog
|
||||
- `/admin/experiments` — Experiment management UI
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
### API Routes
|
||||
- `GET /api/session` — Fetch or create session, sets httpOnly cookie
|
||||
- `GET /api/pricing?productId=X&sessionId=Y&experimentId=Z` — Get product price from provider
|
||||
- `POST /api/ingest` — Ingest event to Kafka via backend
|
||||
- `GET /api/admin/experiments` — List all experiments
|
||||
- `POST /api/admin/experiments/start` — Start new experiment for session
|
||||
- `POST /api/admin/experiments/stop` — Stop experiment by ID
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## Event Catalog
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
|
||||
## Deploy on Vercel
|
||||
|
||||
| Event Name | Category | Payload Example |
|
||||
|------------|----------|-----------------|
|
||||
| `session_start` | Session | `{ sessionId, experimentId?, storeMode, ts, page, eventName, userAgent? }` |
|
||||
| `page_view` | Navigation | `{ sessionId, experimentId?, storeMode, ts, page: "/hotel", eventName: "page_view" }` |
|
||||
| `view_item_page` | Discovery | `{ sessionId, storeMode, ts, page: "/hotel/products", productId: "H001", eventName: "view_item_page" }` |
|
||||
| `learn_more_about_item` | Discovery | `{ sessionId, storeMode, ts, page, productId, eventName: "learn_more_about_item" }` |
|
||||
| `add_item_to_cart` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "add_item_to_cart" }` |
|
||||
| `remove_item` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "remove_item" }` |
|
||||
| `checkout_start` | Cart | `{ sessionId, storeMode, ts, page, eventName: "checkout_start" }` |
|
||||
| `purchase_complete` | Cart | `{ sessionId, storeMode, ts, page, eventName: "purchase_complete", metadata?: { total: 500 } }` |
|
||||
| `search` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "search", metadata: { query: "paris" } }` |
|
||||
| `filter_for_date` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_date", metadata: { from: "2025-01-15", to: "2025-01-20" } }` |
|
||||
| `filter_for_amenities` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_amenities", metadata: { amenities: ["wifi", "pool"] } }` |
|
||||
| `filter_for_price` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_price", metadata: { min: 100, max: 500 } }` |
|
||||
| `sort_change` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "sort_change", metadata: { sort: "price_asc" } }` |
|
||||
| `hover_over_title` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_title", metadata: { duration: 1500 } }` |
|
||||
| `hover_over_paragraph` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_paragraph", metadata: { duration: 2000 } }` |
|
||||
| `hover_over_link` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_link", metadata: { href: "/hotel/products" } }` |
|
||||
| `hover_over_button` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_button", metadata: { label: "Book Now" } }` |
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Route Groups
|
||||
- `(hotel)` — Hotel mode pages
|
||||
- `(airline)` — Airline mode pages
|
||||
- `api/*` — API routes (session, pricing, ingest, admin)
|
||||
|
||||
### Middleware Flow
|
||||
1. Request arrives at Next.js
|
||||
2. Session middleware checks for `phantom_session_id` cookie
|
||||
3. If missing, `/api/session` mints new session + sets cookie
|
||||
4. Store mode (`STORE_MODE` env) determines rendered page variant
|
||||
5. Client-side components fetch pricing via `/api/pricing`
|
||||
6. User interactions emit events to `/api/ingest` → Kafka
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
22
web/package-lock.json
generated
22
web/package-lock.json
generated
@@ -8,10 +8,10 @@
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"kafkajs": "^2.2.4",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1041,6 +1041,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/kafkajs": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
|
||||
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -1607,15 +1616,6 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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,10 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"kafkajs": "^2.2.4",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
status: 'active' | 'stopped';
|
||||
sessionIds: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export default function ExperimentsAdmin() {
|
||||
const { sessionId, isLoading: sessionLoading } = useSession();
|
||||
const [exps, setExps] = useState<Experiment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 handleStart = async () => {
|
||||
if (!sessionId) {
|
||||
setError('no session available');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'start failed');
|
||||
}
|
||||
|
||||
await fetchExps(); // refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (expId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ experimentId: expId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'stop failed');
|
||||
}
|
||||
|
||||
await fetchExps(); // refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">loading session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||
Experiments
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
current session: {sessionId || 'none'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={loading || !sessionId}
|
||||
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'starting...' : 'start experiment'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
experiment id
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
status
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
session count
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
created
|
||||
</th>
|
||||
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{exps.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
no experiments yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
exps.map((exp) => (
|
||||
<tr
|
||||
key={exp.id}
|
||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
|
||||
{exp.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
||||
exp.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
|
||||
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{exp.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||
{exp.sessionIds.length}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
|
||||
{new Date(exp.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{exp.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleStop(exp.id)}
|
||||
disabled={loading}
|
||||
className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
stop
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/airline.css';
|
||||
|
||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="airline">{children}</div>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||
|
||||
export default function AirlineHome() {
|
||||
return (
|
||||
<main>
|
||||
<AirlineHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Navigation } from '@/components/ui';
|
||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
const genRandomFlights = (): Flight[] => {
|
||||
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
||||
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
||||
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
||||
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const depHour = Math.floor(Math.random() * 24);
|
||||
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
|
||||
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
|
||||
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
|
||||
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
|
||||
|
||||
const basePrice = Math.floor(
|
||||
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
|
||||
Math.random() * 300
|
||||
);
|
||||
|
||||
return {
|
||||
id: `flt-${i}`,
|
||||
departure: {
|
||||
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
arrival: {
|
||||
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||
},
|
||||
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
|
||||
stops,
|
||||
cabinClass: cabin,
|
||||
fareRule,
|
||||
refundable: Math.random() > 0.7,
|
||||
basePrice,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function AirlineProducts() {
|
||||
const flights = genRandomFlights();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||
<div className="space-y-4">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAllExperiments } from '@/lib/sessionStore';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const exps = getAllExperiments();
|
||||
return NextResponse.json({ experiments: exps });
|
||||
} catch (err: any) {
|
||||
console.error('experiments list error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createExperiment, getSession } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify session exists
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// generate and create experiment
|
||||
const experimentId = randomUUID();
|
||||
const exp = createExperiment(sessionId, experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp.id,
|
||||
sessionId,
|
||||
status: exp.status,
|
||||
createdAt: exp.createdAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment start error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { experimentId } = body;
|
||||
|
||||
if (!experimentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experimentId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify experiment exists
|
||||
const existing = getExperiment(experimentId);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experiment not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// stop the experiment
|
||||
const exp = stopExperimentById(experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp!.id,
|
||||
status: exp!.status,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment stop error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { EventBase } from '@/lib/events';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const storeMode = process.env.STORE_MODE || 'hotel';
|
||||
const userAgent = req.headers.get('user-agent') || undefined;
|
||||
|
||||
const event: EventBase = {
|
||||
...body,
|
||||
storeMode,
|
||||
userAgent,
|
||||
ts: body.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Backend returned ${res.status}`);
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
|
||||
console.log('[ingest]', event);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('[ingest error]', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface PricingResponse {
|
||||
price: number;
|
||||
currency: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const productId = searchParams.get('productId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const experimentId = searchParams.get('experimentId');
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||
|
||||
// log in dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[pricing-api]', {
|
||||
productId,
|
||||
sessionId,
|
||||
experimentId,
|
||||
storeMode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'productId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// stub: call external pricing provider (random for now)
|
||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||
const price = Math.round(basePrice * 100) / 100;
|
||||
|
||||
const response: PricingResponse = {
|
||||
price,
|
||||
currency: 'EUR',
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getSession, createSession } from '@/lib/sessionStore';
|
||||
|
||||
const COOKIE_NAME = 'phantom_session_id';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// check for existing session cookie
|
||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (existingSession) {
|
||||
const sessionData = getSession(existingSession);
|
||||
return NextResponse.json({
|
||||
sessionId: existingSession,
|
||||
experimentId: sessionData?.experimentId,
|
||||
});
|
||||
}
|
||||
|
||||
// mint new session id
|
||||
const sessionId = randomUUID();
|
||||
createSession(sessionId);
|
||||
|
||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
||||
|
||||
// set httpOnly cookie with security flags
|
||||
res.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.error('session error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
web/src/app/api/track/route.ts
Normal file
33
web/src/app/api/track/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendInteractionEvent } from '@/lib/kafka';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
|
||||
|
||||
if (!sessionId || !eventType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId and eventType required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await sendInteractionEvent({
|
||||
sessionId,
|
||||
eventType,
|
||||
targetEl,
|
||||
targetUrl,
|
||||
metadata,
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('track error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 32px;
|
||||
--border-radius: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -23,7 +12,6 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
@@ -31,79 +19,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 2rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.2s ease;
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: #0051d5;
|
||||
}
|
||||
|
||||
.section-spacing {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/hotel.css';
|
||||
|
||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="hotel">{children}</div>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||
|
||||
export default function HotelHome() {
|
||||
return (
|
||||
<main>
|
||||
<HotelHero />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Navigation } from '@/components/ui';
|
||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const genRandomHotels = (): Hotel[] => {
|
||||
const names = [
|
||||
'Grand Plaza Hotel',
|
||||
'Seaside Resort',
|
||||
'Downtown Suites',
|
||||
'Mountain View Lodge',
|
||||
'City Center Inn',
|
||||
'Luxury Beach Resort',
|
||||
'Urban Boutique Hotel',
|
||||
'Garden View Hotel',
|
||||
];
|
||||
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
|
||||
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const nights = Math.floor(Math.random() * 5) + 1;
|
||||
const basePrice = Math.floor(80 + Math.random() * 220);
|
||||
const selectedAmenities = amenities
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, Math.floor(Math.random() * 3) + 2);
|
||||
|
||||
const today = new Date();
|
||||
const checkInDate = new Date(today);
|
||||
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
|
||||
const checkOutDate = new Date(checkInDate);
|
||||
checkOutDate.setDate(checkInDate.getDate() + nights);
|
||||
|
||||
return {
|
||||
id: `htl-${i}`,
|
||||
name: names[i % names.length],
|
||||
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
|
||||
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
amenities: selectedAmenities,
|
||||
refundable: Math.random() > 0.5,
|
||||
pricePerNight: basePrice,
|
||||
nights,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function HotelProducts() {
|
||||
const hotels = genRandomHotels();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||
<div className="space-y-4">
|
||||
{hotels.map((h) => (
|
||||
<HotelCard key={h.id} hotel={h} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
const durationRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: flight.duration },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: 'price' },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('view_item_page', flight.id, {
|
||||
cabinClass: flight.cabinClass,
|
||||
fareRule: flight.fareRule,
|
||||
price: flight.basePrice,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flight-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.departure.time}</div>
|
||||
<div className="flight-airport">{flight.departure.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-route">
|
||||
<div ref={durationRef} className="flight-duration">{flight.duration}</div>
|
||||
<div className="flight-stops">
|
||||
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.arrival.time}</div>
|
||||
<div className="flight-airport">{flight.arrival.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-pricing">
|
||||
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
|
||||
{flight.refundable && (
|
||||
<div className="badge-value text-xs mb-2">Refundable</div>
|
||||
)}
|
||||
<div ref={priceRef}>
|
||||
<PriceDisplay
|
||||
productId={flight.id}
|
||||
className="fare-price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
|
||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||
|
||||
const PlaneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LocationIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AirlineHero() {
|
||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [departDate, setDepartDate] = useState('');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
||||
};
|
||||
|
||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||
|
||||
return (
|
||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-5xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Book flights at the best prices
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Compare hundreds of airlines and find the perfect flight for your journey
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="search-form">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="mb-6">
|
||||
<RadioGroup
|
||||
name="tripType"
|
||||
value={tripType}
|
||||
onChange={setTripType}
|
||||
options={[
|
||||
{ value: 'roundtrip', label: 'Round-trip' },
|
||||
{ value: 'oneway', label: 'One-way' },
|
||||
{ value: 'multicity', label: 'Multi-city' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="origin">From</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="origin"
|
||||
value={origin}
|
||||
onChange={(e) => setOrigin(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<PlaneIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="destination">To</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="destination"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<LocationIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="departDate">Departure</Label>
|
||||
<DateInput
|
||||
id="departDate"
|
||||
value={departDate}
|
||||
onChange={(e) => setDepartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="returnDate">Return</Label>
|
||||
{tripType === 'roundtrip' ? (
|
||||
<DateInput
|
||||
id="returnDate"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<DateInput id="returnDate" disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||
<div className="sm:col-span-1 lg:col-span-1">
|
||||
<Label htmlFor="passengers">Passengers</Label>
|
||||
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
sublabel="12+ years"
|
||||
value={passengers.adults}
|
||||
min={1}
|
||||
onChange={(v) => setPassengers({ ...passengers, adults: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Children"
|
||||
sublabel="2-11 years"
|
||||
value={passengers.children}
|
||||
onChange={(v) => setPassengers({ ...passengers, children: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Infants"
|
||||
sublabel="Under 2"
|
||||
value={passengers.infants}
|
||||
onChange={(v) => setPassengers({ ...passengers, infants: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" fullWidth>
|
||||
Search Flights
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const AmenityIcon = ({ name }: { name: string }) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
wifi: 'Wi-Fi',
|
||||
pool: 'Pool',
|
||||
gym: 'Gym',
|
||||
parking: 'Parking',
|
||||
breakfast: 'Breakfast',
|
||||
spa: 'Spa',
|
||||
};
|
||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
||||
};
|
||||
|
||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
const titleRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: hotel.name },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: 'price' },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('view_item_page', hotel.id, {
|
||||
roomType: hotel.roomType,
|
||||
price: hotel.pricePerNight,
|
||||
nights: hotel.nights,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hotel-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Image</span>
|
||||
</div>
|
||||
|
||||
<div className="hotel-info">
|
||||
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{hotel.checkIn} - {hotel.checkOut}
|
||||
</div>
|
||||
<div className="hotel-features">
|
||||
{hotel.amenities.map((a) => (
|
||||
<AmenityIcon key={a} name={a} />
|
||||
))}
|
||||
</div>
|
||||
{hotel.refundable && (
|
||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hotel-pricing">
|
||||
<div ref={priceRef}>
|
||||
<PriceDisplay
|
||||
productId={hotel.id}
|
||||
className="price-wrapper"
|
||||
perNight
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
|
||||
const LocationIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function HotelHero() {
|
||||
const [destination, setDestination] = useState('');
|
||||
const [checkIn, setCheckIn] = useState('');
|
||||
const [checkOut, setCheckOut] = useState('');
|
||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log({ destination, checkIn, checkOut, guests });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hero-section min-h-[70vh] flex items-center justify-center">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Find your perfect stay
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Search hotels, compare prices, and book with confidence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="destination">Where to?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="destination"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="City, hotel, or landmark"
|
||||
icon={<LocationIcon />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkIn">Check-in</Label>
|
||||
<DateInput
|
||||
id="checkIn"
|
||||
value={checkIn}
|
||||
onChange={(e) => setCheckIn(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkOut">Check-out</Label>
|
||||
<DateInput
|
||||
id="checkOut"
|
||||
value={checkOut}
|
||||
onChange={(e) => setCheckOut(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<Label htmlFor="guests">Guests & Rooms</Label>
|
||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
value={guests.adults}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Rooms"
|
||||
value={guests.rooms}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<Button type="submit" fullWidth>
|
||||
Search Hotels
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type BtnVariant = 'primary' | 'secondary';
|
||||
|
||||
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: BtnVariant;
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
|
||||
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { InputHTMLAttributes } from 'react';
|
||||
|
||||
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
||||
|
||||
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
||||
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Dropdown({ label, children }: DropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="input-field flex justify-between items-center w-full"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CounterProps {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
onChange: (val: number) => void;
|
||||
}
|
||||
|
||||
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
disabled={value <= min}
|
||||
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
disabled={value >= max}
|
||||
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Input({ icon, className = '', style, ...props }: InpProps) {
|
||||
const padClass = icon ? 'pl-10' : '';
|
||||
// Fallback if a custom CSS rule still overrides Tailwind
|
||||
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`input-field ${className} ${padClass}`}
|
||||
style={mergedStyle}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ReactNode, LabelHTMLAttributes } from 'react';
|
||||
|
||||
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Label({ children, className = '', ...props }: LblProps) {
|
||||
return (
|
||||
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
const path = usePathname();
|
||||
const isActive = path === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Navigation() {
|
||||
return (
|
||||
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center space-x-1">
|
||||
<NavLink href="/">Home</NavLink>
|
||||
<NavLink href="/products">Products</NavLink>
|
||||
<NavLink href="/search">Search</NavLink>
|
||||
<NavLink href="/cart">Cart</NavLink>
|
||||
<NavLink href="/checkout">Checkout</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface PriceDisplayProps {
|
||||
productId: string;
|
||||
className?: string;
|
||||
perNight?: boolean;
|
||||
}
|
||||
|
||||
interface PricingData {
|
||||
price: number;
|
||||
currency: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
sessionId: string;
|
||||
experimentId?: string;
|
||||
}
|
||||
|
||||
const fetchSession = async (): Promise<SessionData> => {
|
||||
try {
|
||||
const res = await fetch('/api/session');
|
||||
const data = await res.json();
|
||||
return {
|
||||
sessionId: data.sessionId || '',
|
||||
experimentId: data.experimentId || '',
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('failed to fetch session:', err);
|
||||
return { sessionId: '', experimentId: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', { // like an std localization
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
|
||||
const cacheTime = new Date(cachedAt).getTime();
|
||||
const now = Date.now();
|
||||
return now - cacheTime > thresholdMs;
|
||||
};
|
||||
|
||||
export default function PriceDisplay({
|
||||
productId,
|
||||
className = '',
|
||||
perNight = false,
|
||||
}: PriceDisplayProps) {
|
||||
const sessionRef = useRef<SessionData | null>(null);
|
||||
const [data, setData] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initAndFetch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// fetch session if not already loaded
|
||||
if (!sessionRef.current) {
|
||||
sessionRef.current = await fetchSession();
|
||||
}
|
||||
|
||||
const { sessionId, experimentId } = sessionRef.current;
|
||||
|
||||
if (!sessionId) {
|
||||
setError('Invalid session');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
sessionId,
|
||||
experimentId: experimentId || '',
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/pricing?${params.toString()}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch price: ${res.status}`);
|
||||
}
|
||||
|
||||
const pricingData: PricingData = await res.json();
|
||||
setData(pricingData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAndFetch();
|
||||
}, [productId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`price-loading ${className}`}>
|
||||
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className={`price-error ${className}`}>
|
||||
<span className="text-red-500 text-sm">Price unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isStale = isCacheStale(data.cachedAt);
|
||||
const formattedPrice = formatPrice(data.price, data.currency);
|
||||
|
||||
return (
|
||||
<div className={`price-display ${className}`}>
|
||||
<div className="price-amount">
|
||||
{formattedPrice}
|
||||
{perNight && <span className="text-xs ml-1">/night</span>}
|
||||
</div>
|
||||
{isStale && (
|
||||
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
|
||||
prices may be outdated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
interface RadioOpt<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RadioGrpProps<T extends string> {
|
||||
name: string;
|
||||
options: RadioOpt<T>[];
|
||||
value: T;
|
||||
onChange: (val: T) => void;
|
||||
}
|
||||
|
||||
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { default as Button } from './Button';
|
||||
export { default as Label } from './Label';
|
||||
export { default as Input } from './Input';
|
||||
export { default as DateInput } from './DateInput';
|
||||
export { default as RadioGroup } from './RadioGroup';
|
||||
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
||||
export { default as Navigation } from './Navigation';
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (
|
||||
eventName: EventName,
|
||||
productId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
) => {
|
||||
const e = new CustomEvent('definedInteraction', {
|
||||
detail: { eventName, productId, metadata },
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
interface UseHoverTrackingOptions {
|
||||
eventName: EventName;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
|
||||
}
|
||||
|
||||
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
|
||||
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
|
||||
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
|
||||
: 1500;
|
||||
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
|
||||
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const startRef = useRef<number | undefined>(undefined);
|
||||
|
||||
return useCallback((node: HTMLElement | null) => {
|
||||
if (!node) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
startRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
const dwellTime = Date.now() - startRef.current!;
|
||||
dispatchInteraction(eventName, productId, {
|
||||
...metadata,
|
||||
dwellTime,
|
||||
});
|
||||
}, threshold);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener('mouseenter', onEnter);
|
||||
node.addEventListener('mouseleave', onLeave);
|
||||
|
||||
return () => {
|
||||
node.removeEventListener('mouseenter', onEnter);
|
||||
node.removeEventListener('mouseleave', onLeave);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [eventName, productId, metadata, threshold]);
|
||||
};
|
||||
@@ -1,86 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import '@/lib/experiments' // ensure experiments lib is loaded
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const fetchSessionId = async (): Promise<string> => {
|
||||
try {
|
||||
const res = await fetch('/api/session');
|
||||
const data = await res.json();
|
||||
return data.sessionId || '';
|
||||
} catch (err) {
|
||||
console.error('failed to fetch session:', err);
|
||||
return '';
|
||||
}
|
||||
const genSessionId = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
let sid = sessionStorage.getItem('phantom_session_id');
|
||||
if (!sid) {
|
||||
sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
sessionStorage.setItem('phantom_session_id', sid);
|
||||
// TODO: when creating new id send to exepriemtn tracking db
|
||||
// match between sesion-id and experiment-id for this session
|
||||
// so that we can identify all interactions aligning with a specific experiment goal.
|
||||
}
|
||||
return sid;
|
||||
};
|
||||
|
||||
const track = async (ev: {
|
||||
sessionId: string;
|
||||
eventName: EventName;
|
||||
page: string;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
targetEl?: string;
|
||||
targetUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}) => {
|
||||
try {
|
||||
await fetch('/api/ingest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ev),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('track failed:', err);
|
||||
}
|
||||
try {
|
||||
await fetch('/api/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ev),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('track failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const useInteractionTracking = () => {
|
||||
const sidRef = useRef<string>('');
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// fetch session id from httpOnly cookie via API
|
||||
fetchSessionId().then((sid) => {
|
||||
sidRef.current = sid;
|
||||
setReady(true);
|
||||
});
|
||||
sidRef.current = genSessionId();
|
||||
|
||||
const handlePageView = () => {
|
||||
if (!sidRef.current) return;
|
||||
const page = window.location.pathname;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement;
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventName: 'page_view',
|
||||
page,
|
||||
eventType: 'click',
|
||||
targetEl: tgt.tagName,
|
||||
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
|
||||
metadata: {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
path: window.location.pathname,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: 'scroll',
|
||||
metadata: {
|
||||
scrollY: window.scrollY,
|
||||
path: window.location.pathname,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageView = () => {
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventType: 'pageview',
|
||||
metadata: {
|
||||
path: window.location.pathname,
|
||||
referrer: document.referrer,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// called for canonical events dispatched via custom events
|
||||
const handleDefinedInteraction = (e: Event) => {
|
||||
if (!sidRef.current) return;
|
||||
const customEvent = e as CustomEvent<{
|
||||
eventName: EventName;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
const page = window.location.pathname;
|
||||
enum DefinedInteractions {
|
||||
ADD_TO_CART = 'add_to_cart',
|
||||
PURCHASE = 'purchase',
|
||||
}
|
||||
|
||||
// called when clicking on "Add to Cart" button or "Purchase" button
|
||||
const handleDefinedInteraction = (
|
||||
interactionType: DefinedInteractions,
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
track({
|
||||
sessionId: sidRef.current,
|
||||
eventName: customEvent.detail.eventName,
|
||||
page,
|
||||
productId: customEvent.detail.productId,
|
||||
metadata: customEvent.detail.metadata,
|
||||
eventType: interactionType,
|
||||
metadata: {
|
||||
path: window.location.pathname,
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// wait for session to be ready before tracking
|
||||
if (!ready) return;
|
||||
|
||||
handlePageView();
|
||||
document.addEventListener('definedInteraction', handleDefinedInteraction);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('definedInteraction', (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
|
||||
});
|
||||
// TOO NOISY: enable if needed but tbh not worth it
|
||||
//window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('definedInteraction', handleDefinedInteraction);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('definedInteraction', (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
|
||||
});
|
||||
//window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [ready]);
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type SessionState = {
|
||||
sessionId: string | null;
|
||||
experimentId: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const useSession = () => {
|
||||
const [state, setState] = useState<SessionState>({
|
||||
sessionId: null,
|
||||
experimentId: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/session');
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
setState({
|
||||
sessionId: data.sessionId || null,
|
||||
experimentId: data.experimentId || null,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('session fetch error:', err);
|
||||
setState({ sessionId: null, experimentId: null, isLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
type Env = z.infer<typeof envSchema>;
|
||||
const envSchema = z.object({
|
||||
STORE_MODE: z.enum(['hotel', 'airline'], {
|
||||
message: 'STORE_MODE must be either "hotel" or "airline"'
|
||||
}),
|
||||
NEXT_PUBLIC_API_BASE: z.string().url({
|
||||
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
|
||||
}),
|
||||
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
|
||||
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
|
||||
}),
|
||||
});
|
||||
|
||||
// parse and validate env at module load, fail fast with descriptive errors
|
||||
const parseEnv = (): Env => {
|
||||
const result = envSchema.safeParse({
|
||||
STORE_MODE: process.env.STORE_MODE,
|
||||
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||
});
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
||||
throw new Error(`Environment validation failed:\n${errors}`);
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const config: Env = parseEnv();
|
||||
@@ -1,91 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// canonical events for tracking user interactions
|
||||
export type EventName =
|
||||
// navigation & discovery
|
||||
| 'page_view'
|
||||
| 'view_item_page'
|
||||
| 'learn_more_about_item'
|
||||
// cart operations
|
||||
| 'add_item_to_cart'
|
||||
| 'remove_item'
|
||||
| 'checkout_start'
|
||||
| 'purchase_complete'
|
||||
// filtering & search
|
||||
| 'search'
|
||||
| 'filter_for_date'
|
||||
| 'filter_for_amenities'
|
||||
| 'filter_for_price'
|
||||
| 'sort_change'
|
||||
// dwell signals (Ns threshold)
|
||||
| 'hover_over_title'
|
||||
| 'hover_over_paragraph'
|
||||
| 'hover_over_link'
|
||||
| 'hover_over_button'
|
||||
// session
|
||||
| 'session_start';
|
||||
|
||||
export const eventNames: readonly EventName[] = [
|
||||
'page_view',
|
||||
'view_item_page',
|
||||
'learn_more_about_item',
|
||||
'add_item_to_cart',
|
||||
'remove_item',
|
||||
'checkout_start',
|
||||
'purchase_complete',
|
||||
'search',
|
||||
'filter_for_date',
|
||||
'filter_for_amenities',
|
||||
'filter_for_price',
|
||||
'sort_change',
|
||||
'hover_over_title',
|
||||
'hover_over_paragraph',
|
||||
'hover_over_link',
|
||||
'hover_over_button',
|
||||
'session_start',
|
||||
] as const;
|
||||
|
||||
export interface EventBase {
|
||||
sessionId: string;
|
||||
experimentId?: string;
|
||||
storeMode: 'hotel' | 'airline';
|
||||
ts: string; // ISO8601
|
||||
page: string;
|
||||
eventName: EventName;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
// zod schema for runtime validation
|
||||
export const eventBaseSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
experimentId: z.string().optional(),
|
||||
storeMode: z.enum(['hotel', 'airline']),
|
||||
ts: z.string().datetime(), // validates ISO8601
|
||||
page: z.string().min(1),
|
||||
eventName: z.enum([
|
||||
'page_view',
|
||||
'view_item_page',
|
||||
'learn_more_about_item',
|
||||
'add_item_to_cart',
|
||||
'remove_item',
|
||||
'checkout_start',
|
||||
'purchase_complete',
|
||||
'search',
|
||||
'filter_for_date',
|
||||
'filter_for_amenities',
|
||||
'filter_for_price',
|
||||
'sort_change',
|
||||
'hover_over_title',
|
||||
'hover_over_paragraph',
|
||||
'hover_over_link',
|
||||
'hover_over_button',
|
||||
'session_start',
|
||||
]),
|
||||
productId: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
userAgent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EventBaseValidated = z.infer<typeof eventBaseSchema>;
|
||||
42
web/src/lib/kafka.ts
Normal file
42
web/src/lib/kafka.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Kafka, Producer } from 'kafkajs';
|
||||
|
||||
let producer: Producer | null = null;
|
||||
|
||||
const kafka = new Kafka({
|
||||
clientId: 'phantom-web',
|
||||
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
|
||||
});
|
||||
|
||||
export const getProducer = async (): Promise<Producer> => {
|
||||
if (!producer) {
|
||||
producer = kafka.producer();
|
||||
await producer.connect();
|
||||
}
|
||||
return producer;
|
||||
};
|
||||
|
||||
export const sendInteractionEvent = async (ev: {
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
targetEl?: string;
|
||||
targetUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
ts: number;
|
||||
}) => {
|
||||
const p = await getProducer();
|
||||
// add to the metadata
|
||||
await p.send({
|
||||
topic: 'user-interactions',
|
||||
messages: [{
|
||||
key: ev.sessionId,
|
||||
value: JSON.stringify(ev),
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
export const disconnect = async () => {
|
||||
if (producer) {
|
||||
await producer.disconnect();
|
||||
producer = null;
|
||||
}
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
type SessionData = {
|
||||
experimentId?: string;
|
||||
startedAt: number;
|
||||
status: 'active' | 'stopped';
|
||||
};
|
||||
|
||||
type ExperimentData = {
|
||||
id: string;
|
||||
status: 'active' | 'stopped';
|
||||
sessionIds: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const store = new Map<string, SessionData>();
|
||||
const experiments = new Map<string, ExperimentData>();
|
||||
|
||||
const cfg = {
|
||||
key: process.env.AIRTABLE_API_KEY,
|
||||
base: process.env.AIRTABLE_BASE_ID,
|
||||
table: process.env.AIRTABLE_TABLE_NAME || 'Sessions',
|
||||
};
|
||||
|
||||
// sync session to airtable if credentials present
|
||||
const syncToAirtable = async (sid: string, data: SessionData) => {
|
||||
if (!cfg.key || !cfg.base) return; // skip if not configured
|
||||
|
||||
try {
|
||||
const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`;
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fields: {
|
||||
sessionId: sid,
|
||||
experimentId: data.experimentId || '',
|
||||
startedAt: new Date(data.startedAt).toISOString(),
|
||||
status: data.status,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('airtable sync failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const getSession = (sid: string) => store.get(sid);
|
||||
|
||||
export const createSession = (sid: string) => {
|
||||
const data: SessionData = { startedAt: Date.now(), status: 'active' };
|
||||
store.set(sid, data);
|
||||
syncToAirtable(sid, data); // async fire-and-forget
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setExperiment = (sid: string, expId: string) => {
|
||||
const data = store.get(sid) || createSession(sid);
|
||||
data.experimentId = expId;
|
||||
store.set(sid, data);
|
||||
syncToAirtable(sid, data);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const stopExperiment = (sid: string) => {
|
||||
const data = store.get(sid);
|
||||
if (data) {
|
||||
data.status = 'stopped';
|
||||
store.set(sid, data);
|
||||
syncToAirtable(sid, data);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// experiment-level operations
|
||||
export const createExperiment = (sid: string, expId: string) => {
|
||||
const exp: ExperimentData = {
|
||||
id: expId,
|
||||
status: 'active',
|
||||
sessionIds: [sid],
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
experiments.set(expId, exp);
|
||||
setExperiment(sid, expId); // link session to experiment
|
||||
console.log(`experiment ${expId} started with session ${sid}`);
|
||||
return exp;
|
||||
};
|
||||
|
||||
export const stopExperimentById = (expId: string) => {
|
||||
const exp = experiments.get(expId);
|
||||
if (exp) {
|
||||
exp.status = 'stopped';
|
||||
experiments.set(expId, exp);
|
||||
console.log(`experiment ${expId} stopped`);
|
||||
}
|
||||
return exp;
|
||||
};
|
||||
|
||||
export const getExperiment = (expId: string) => experiments.get(expId);
|
||||
|
||||
export const getAllExperiments = () => Array.from(experiments.values());
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export function proxy(req: NextRequest) {
|
||||
const mode = process.env.STORE_MODE;
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// skip rewrites for api routes, admin routes, static files, and next internals
|
||||
if (
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/admin') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/static') ||
|
||||
pathname.includes('.')
|
||||
// TODO: add robots.txt and sitemap.xml if needed here
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// already prefixed with mode
|
||||
if (pathname.startsWith(`/${mode}`)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// rewrite root and unprefixed paths to mode-specific route group
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`;
|
||||
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// match all paths except those starting with _next/static, _next/image, favicon.ico
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
@@ -1,321 +0,0 @@
|
||||
/* Airline Platform - Sky Blue Theme */
|
||||
|
||||
@layer base {
|
||||
[data-mode="airline"] {
|
||||
--accent-primary: #007aff;
|
||||
--accent-secondary: #4caf50;
|
||||
--accent-warning: #ff3b30;
|
||||
--accent-primary-hover: #0051d5;
|
||||
--accent-primary-light: #e6f2ff;
|
||||
--text-accent: #007aff;
|
||||
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-mode="airline"] {
|
||||
--primary-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .btn-primary {
|
||||
background-color: var(--accent-primary) !important;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 24px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .btn-primary:hover {
|
||||
background-color: var(--accent-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--accent-primary);
|
||||
border: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .btn-secondary:hover {
|
||||
background-color: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .badge-value {
|
||||
background-color: var(--accent-secondary);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .badge-warning {
|
||||
background-color: var(--accent-warning);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .search-form {
|
||||
background: var(--bg-primary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-card {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 3fr 2fr;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-timing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-time {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-airport {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-route {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-duration {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-stops {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .flight-pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .fare-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .fare-option:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .fare-class {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .fare-price {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-price-bar {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-option {
|
||||
min-width: 100px;
|
||||
padding: var(--spacing-sm);
|
||||
text-align: center;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-option:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-option.active {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .date-price {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .progress-wizard {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 800px;
|
||||
margin: var(--spacing-lg) auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e0e0e0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step.active .wizard-number {
|
||||
background: var(--accent-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step.completed .wizard-number {
|
||||
background: var(--accent-secondary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .wizard-step.active .wizard-label {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="airline"] a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-mode="airline"] a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .input-field {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .input-field:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .filter-sidebar {
|
||||
background: var(--bg-primary);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .filter-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .filter-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .checkbox-label:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="airline"] .hero-section {
|
||||
background: var(--hero-bg);
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
/* Hotel Platform - Action Blue Theme */
|
||||
|
||||
@layer base {
|
||||
[data-mode="hotel"] {
|
||||
--accent-primary: #007aff;
|
||||
--accent-secondary: #4caf50;
|
||||
--accent-warning: #d9534f;
|
||||
--accent-primary-hover: #0051d5;
|
||||
--accent-primary-light: #e6f2ff;
|
||||
--text-accent: #007aff;
|
||||
--bg-tertiary: #f5f5f7;
|
||||
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-mode="hotel"] {
|
||||
--primary-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .btn-primary {
|
||||
background-color: var(--accent-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 24px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .btn-primary:hover {
|
||||
background-color: var(--accent-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--accent-primary);
|
||||
border: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .btn-secondary:hover {
|
||||
background-color: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .badge-value {
|
||||
background-color: var(--accent-secondary);
|
||||
color: #ffffff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .badge-warning {
|
||||
background-color: var(--accent-warning);
|
||||
color: #ffffff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .badge-rating {
|
||||
background-color: var(--accent-primary);
|
||||
color: #ffffff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .search-form {
|
||||
background: var(--bg-primary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-card {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .image-carousel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .carousel-nav {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-sm);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .carousel-dot.active {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-info {
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-location {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .rating-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .feature-tag {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hotel-pricing {
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-wrapper {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-amount {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-unit {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-original {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .urgency-message {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-warning);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .free-cancellation {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-secondary);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .filter-sidebar {
|
||||
background: var(--bg-primary);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .filter-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .filter-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .filter-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .checkbox-label:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .checkbox-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .price-slider {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .slider-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .slider-range {
|
||||
height: 100%;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .slider-values {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .map-toggle {
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .map-toggle:hover {
|
||||
background: var(--accent-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .sort-dropdown {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .sort-dropdown:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .view-toggle {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .view-button {
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .view-button.active {
|
||||
background: var(--accent-primary);
|
||||
color: #ffffff;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .input-field {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .input-field:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--accent-primary-light);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .tab-navigation {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .tab-item {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .tab-item:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .tab-item.active {
|
||||
color: var(--accent-primary);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
[data-mode="hotel"] .hero-section {
|
||||
background: var(--hero-bg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user