mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)
* chore: cleaning gitignore * formating and env documentation * feat: context switching of hotel/airline depndent on env var via middleware * fixed alignment and building * wrong file * prods * fixed applying style * better session cookie management * tentative session storage with maybe using airtable * migrated api of ingestion * events and products apge * fixing build * 13 create outline for research paper draft (#18) * updated outline for paper from issue * extra paper sections and some formalization of series data * algorithms and acknowledgements * updated outline for paper from issue * upadted text formating * event unification * refactor tracking to ues callbacks instead of refs * implement a pricing display api with session passing * moved middleware to proxy according to new changes in Nextjs * refactoed kafka ingestion to go via backend not web-db * Refactor docker-compose services to use individual Dockerfiles (#20) * Initial plan * Refactor services into individual Dockerfiles Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * Add EXPOSE directives to all Dockerfiles with port documentation Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * fixing small bugs and adding exepriments to tracking * added some doc
This commit is contained in:
21
.env.example
21
.env.example
@@ -1,5 +1,18 @@
|
|||||||
HOSTNAME=localhost
|
# Network configuration
|
||||||
|
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||||
|
|
||||||
# PORTS
|
# Application configuration
|
||||||
KAFKA_PORT=9092
|
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
||||||
REDIS_PORT=6377
|
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
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
**/.env
|
**/.env
|
||||||
**/.venv
|
**/.venv
|
||||||
**/__pycache__
|
**/.virtual_documents/
|
||||||
|
**/__pycache__/
|
||||||
**/.ipynb_checkpoints/
|
**/.ipynb_checkpoints/
|
||||||
**/.virtual_documents/
|
|
||||||
@@ -1 +1,5 @@
|
|||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
|
||||||
|
- https://phantom-hotel.vercel.app/
|
||||||
|
- https://phantom-airline.vercel.app/
|
||||||
|
|
||||||
|
|||||||
99
backend/server/app.py
Normal file
99
backend/server/app.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 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)
|
||||||
5
backend/server/requirements.txt
Normal file
5
backend/server/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
kafka-python==2.0.2
|
||||||
|
pydantic==2.5.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
services:
|
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:
|
redis:
|
||||||
container_name: "PHANTOM-redis"
|
container_name: "PHANTOM-redis"
|
||||||
build:
|
build:
|
||||||
@@ -9,6 +23,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- phantom_redis_data:/data
|
- phantom_redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
zookeeper:
|
zookeeper:
|
||||||
container_name: "PHANTOM-zookeeper"
|
container_name: "PHANTOM-zookeeper"
|
||||||
build:
|
build:
|
||||||
|
|||||||
12
docker/backend.Dockerfile
Normal file
12
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/server/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/server/app.py .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@
|
|||||||
commentstyle=\color{green!60!black},
|
commentstyle=\color{green!60!black},
|
||||||
stringstyle=\color{red},
|
stringstyle=\color{red},
|
||||||
showstringspaces=false,
|
showstringspaces=false,
|
||||||
captionpos=b
|
captionpos=b,
|
||||||
|
inputencoding=utf8,
|
||||||
|
extendedchars=true,
|
||||||
|
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
||||||
}
|
}
|
||||||
|
|
||||||
% Use biblatex instead of natbib (acmart default)
|
% Use biblatex instead of natbib (acmart default)
|
||||||
|
|||||||
@@ -12,3 +12,86 @@ The webapp should serve under the / route the landing page which for both platfo
|
|||||||
- /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.
|
- /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.
|
- /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.
|
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# install deps
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# set store mode (hotel or airline)
|
||||||
|
export STORE_MODE=hotel
|
||||||
|
|
||||||
|
# run dev server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server runs on `http://localhost:3000`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## Event Catalog
|
||||||
|
|
||||||
|
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
|
||||||
|
|
||||||
|
| 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" } }` |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
22
web/package-lock.json
generated
22
web/package-lock.json
generated
@@ -8,10 +8,10 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"kafkajs": "^2.2.4",
|
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1041,15 +1041,6 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
@@ -1616,6 +1607,15 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"kafkajs": "^2.2.4",
|
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
199
web/src/app/admin/experiments/page.tsx
Executable file
199
web/src/app/admin/experiments/page.tsx
Executable file
@@ -0,0 +1,199 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
web/src/app/airline/layout.tsx
Normal file
6
web/src/app/airline/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import '@/styles/airline.css';
|
||||||
|
|
||||||
|
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <div data-mode="airline">{children}</div>;
|
||||||
|
}
|
||||||
9
web/src/app/airline/page.tsx
Normal file
9
web/src/app/airline/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||||
|
|
||||||
|
export default function AirlineHome() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<AirlineHero />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
web/src/app/airline/products/page.tsx
Normal file
74
web/src/app/airline/products/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/app/api/admin/experiments/route.ts
Normal file
15
web/src/app/api/admin/experiments/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
43
web/src/app/api/admin/experiments/start/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { createExperiment, getSession } from '@/lib/sessionStore';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { sessionId } = body;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'sessionId required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify session exists
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'session not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate and create experiment
|
||||||
|
const experimentId = randomUUID();
|
||||||
|
const exp = createExperiment(sessionId, experimentId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
experimentId: exp.id,
|
||||||
|
sessionId,
|
||||||
|
status: exp.status,
|
||||||
|
createdAt: exp.createdAt,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('experiment start error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
39
web/src/app/api/admin/experiments/stop/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { experimentId } = body;
|
||||||
|
|
||||||
|
if (!experimentId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'experimentId required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify experiment exists
|
||||||
|
const existing = getExperiment(experimentId);
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'experiment not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop the experiment
|
||||||
|
const exp = stopExperimentById(experimentId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
experimentId: exp!.id,
|
||||||
|
status: exp!.status,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('experiment stop error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
web/src/app/api/ingest/route.ts
Normal file
42
web/src/app/api/ingest/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import type { EventBase } from '@/lib/events';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const storeMode = process.env.STORE_MODE || 'hotel';
|
||||||
|
const userAgent = req.headers.get('user-agent') || undefined;
|
||||||
|
|
||||||
|
const event: EventBase = {
|
||||||
|
...body,
|
||||||
|
storeMode,
|
||||||
|
userAgent,
|
||||||
|
ts: body.ts || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Backend returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
|
||||||
|
console.log('[ingest]', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[ingest error]', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
web/src/app/api/pricing/route.ts
Normal file
45
web/src/app/api/pricing/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
46
web/src/app/api/session/route.ts
Normal file
46
web/src/app/api/session/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { sendInteractionEvent } from '@/lib/kafka';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
|
|
||||||
|
|
||||||
if (!sessionId || !eventType) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'sessionId and eventType required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendInteractionEvent({
|
|
||||||
sessionId,
|
|
||||||
eventType,
|
|
||||||
targetEl,
|
|
||||||
targetUrl,
|
|
||||||
metadata,
|
|
||||||
ts: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('track error:', err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
@@ -66,7 +69,9 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -86,13 +91,19 @@ input, select, textarea {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
background-color: #007aff;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: #0051d5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-spacing {
|
.section-spacing {
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
web/src/app/hotel/layout.tsx
Normal file
6
web/src/app/hotel/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import '@/styles/hotel.css';
|
||||||
|
|
||||||
|
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <div data-mode="hotel">{children}</div>;
|
||||||
|
}
|
||||||
9
web/src/app/hotel/page.tsx
Normal file
9
web/src/app/hotel/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||||
|
|
||||||
|
export default function HotelHome() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<HotelHero />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
web/src/app/hotel/products/page.tsx
Normal file
75
web/src/app/hotel/products/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web/src/components/feats/airline/AirlineCard.tsx
Normal file
87
web/src/components/feats/airline/AirlineCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
web/src/components/feats/airline/AirlineHero.tsx
Normal file
156
web/src/components/feats/airline/AirlineHero.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
web/src/components/feats/hotel/HotelCard.tsx
Normal file
98
web/src/components/feats/hotel/HotelCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/components/feats/hotel/HotelHero.tsx
Normal file
103
web/src/components/feats/hotel/HotelHero.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
web/src/components/ui/Button.tsx
Normal file
20
web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
type BtnVariant = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: BtnVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
|
||||||
|
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
|
||||||
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/ui/DateInput.tsx
Normal file
7
web/src/components/ui/DateInput.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { InputHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
||||||
|
|
||||||
|
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
||||||
|
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
||||||
|
}
|
||||||
83
web/src/components/ui/Dropdown.tsx
Normal file
83
web/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dropdown({ label, children }: DropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="input-field flex justify-between items-center w-full"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterProps {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
onChange: (val: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||||
|
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(Math.max(min, value - 1))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(Math.min(max, value + 1))}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/components/ui/Input.tsx
Normal file
29
web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({ icon, className = '', style, ...props }: InpProps) {
|
||||||
|
const padClass = icon ? 'pl-10' : '';
|
||||||
|
// Fallback if a custom CSS rule still overrides Tailwind
|
||||||
|
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`input-field ${className} ${padClass}`}
|
||||||
|
style={mergedStyle}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
web/src/components/ui/Label.tsx
Normal file
13
web/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ReactNode, LabelHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Label({ children, className = '', ...props }: LblProps) {
|
||||||
|
return (
|
||||||
|
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
web/src/components/ui/Navigation.tsx
Normal file
48
web/src/components/ui/Navigation.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import type { EventName } from '@/lib/events';
|
||||||
|
|
||||||
|
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
|
||||||
|
const e = new CustomEvent('definedInteraction', {
|
||||||
|
detail: { eventName, metadata },
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||||
|
const path = usePathname();
|
||||||
|
const isActive = path === href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||||
|
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Navigation() {
|
||||||
|
return (
|
||||||
|
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between h-16">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<NavLink href="/">Home</NavLink>
|
||||||
|
<NavLink href="/products">Products</NavLink>
|
||||||
|
<NavLink href="/search">Search</NavLink>
|
||||||
|
<NavLink href="/cart">Cart</NavLink>
|
||||||
|
<NavLink href="/checkout">Checkout</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
web/src/components/ui/PriceDisplay.tsx
Normal file
136
web/src/components/ui/PriceDisplay.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface PriceDisplayProps {
|
||||||
|
productId: string;
|
||||||
|
className?: string;
|
||||||
|
perNight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingData {
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
cachedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionData {
|
||||||
|
sessionId: string;
|
||||||
|
experimentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSession = async (): Promise<SessionData> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/session');
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
sessionId: data.sessionId || '',
|
||||||
|
experimentId: data.experimentId || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to fetch session:', err);
|
||||||
|
return { sessionId: '', experimentId: '' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat('en-US', { // like an std localization
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
|
||||||
|
const cacheTime = new Date(cachedAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
return now - cacheTime > thresholdMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PriceDisplay({
|
||||||
|
productId,
|
||||||
|
className = '',
|
||||||
|
perNight = false,
|
||||||
|
}: PriceDisplayProps) {
|
||||||
|
const sessionRef = useRef<SessionData | null>(null);
|
||||||
|
const [data, setData] = useState<PricingData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAndFetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// fetch session if not already loaded
|
||||||
|
if (!sessionRef.current) {
|
||||||
|
sessionRef.current = await fetchSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId, experimentId } = sessionRef.current;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
setError('Invalid session');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
productId,
|
||||||
|
sessionId,
|
||||||
|
experimentId: experimentId || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/pricing?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch price: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingData: PricingData = await res.json();
|
||||||
|
setData(pricingData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAndFetch();
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={`price-loading ${className}`}>
|
||||||
|
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className={`price-error ${className}`}>
|
||||||
|
<span className="text-red-500 text-sm">Price unavailable</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStale = isCacheStale(data.cachedAt);
|
||||||
|
const formattedPrice = formatPrice(data.price, data.currency);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`price-display ${className}`}>
|
||||||
|
<div className="price-amount">
|
||||||
|
{formattedPrice}
|
||||||
|
{perNight && <span className="text-xs ml-1">/night</span>}
|
||||||
|
</div>
|
||||||
|
{isStale && (
|
||||||
|
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
|
||||||
|
prices may be outdated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/ui/RadioGroup.tsx
Normal file
33
web/src/components/ui/RadioGroup.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface RadioOpt<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadioGrpProps<T extends string> {
|
||||||
|
name: string;
|
||||||
|
options: RadioOpt<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (val: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt.value} className="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={opt.value}
|
||||||
|
checked={value === opt.value}
|
||||||
|
onChange={(e) => onChange(e.target.value as T)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/ui/index.ts
Normal file
7
web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Button } from './Button';
|
||||||
|
export { default as Label } from './Label';
|
||||||
|
export { default as Input } from './Input';
|
||||||
|
export { default as DateInput } from './DateInput';
|
||||||
|
export { default as RadioGroup } from './RadioGroup';
|
||||||
|
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
||||||
|
export { default as Navigation } from './Navigation';
|
||||||
63
web/src/hooks/useHoverTracking.ts
Normal file
63
web/src/hooks/useHoverTracking.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import type { EventName } from '@/lib/events';
|
||||||
|
|
||||||
|
const dispatchInteraction = (
|
||||||
|
eventName: EventName,
|
||||||
|
productId?: string,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
const e = new CustomEvent('definedInteraction', {
|
||||||
|
detail: { eventName, productId, metadata },
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseHoverTrackingOptions {
|
||||||
|
eventName: EventName;
|
||||||
|
productId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
|
||||||
|
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
|
||||||
|
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
|
||||||
|
: 1500;
|
||||||
|
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const startRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
return useCallback((node: HTMLElement | null) => {
|
||||||
|
if (!node) {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
startRef.current = Date.now();
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
const dwellTime = Date.now() - startRef.current!;
|
||||||
|
dispatchInteraction(eventName, productId, {
|
||||||
|
...metadata,
|
||||||
|
dwellTime,
|
||||||
|
});
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLeave = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', onEnter);
|
||||||
|
node.addEventListener('mouseleave', onLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener('mouseenter', onEnter);
|
||||||
|
node.removeEventListener('mouseleave', onLeave);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [eventName, productId, metadata, threshold]);
|
||||||
|
};
|
||||||
@@ -1,117 +1,86 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import '@/lib/experiments' // ensure experiments lib is loaded
|
import '@/lib/experiments' // ensure experiments lib is loaded
|
||||||
|
import type { EventName } from '@/lib/events';
|
||||||
|
|
||||||
const genSessionId = () => {
|
const fetchSessionId = async (): Promise<string> => {
|
||||||
if (typeof window === 'undefined') return '';
|
try {
|
||||||
let sid = sessionStorage.getItem('phantom_session_id');
|
const res = await fetch('/api/session');
|
||||||
if (!sid) {
|
const data = await res.json();
|
||||||
sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
return data.sessionId || '';
|
||||||
sessionStorage.setItem('phantom_session_id', sid);
|
} catch (err) {
|
||||||
// TODO: when creating new id send to exepriemtn tracking db
|
console.error('failed to fetch session:', err);
|
||||||
// match between sesion-id and experiment-id for this session
|
return '';
|
||||||
// so that we can identify all interactions aligning with a specific experiment goal.
|
}
|
||||||
}
|
|
||||||
return sid;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const track = async (ev: {
|
const track = async (ev: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
eventType: string;
|
eventName: EventName;
|
||||||
targetEl?: string;
|
page: string;
|
||||||
targetUrl?: string;
|
productId?: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/track', {
|
await fetch('/api/ingest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(ev),
|
body: JSON.stringify(ev),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('track failed:', err);
|
console.error('track failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInteractionTracking = () => {
|
export const useInteractionTracking = () => {
|
||||||
const sidRef = useRef<string>('');
|
const sidRef = useRef<string>('');
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sidRef.current = genSessionId();
|
// fetch session id from httpOnly cookie via API
|
||||||
|
fetchSessionId().then((sid) => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
sidRef.current = sid;
|
||||||
const tgt = e.target as HTMLElement;
|
setReady(true);
|
||||||
track({
|
});
|
||||||
sessionId: sidRef.current,
|
|
||||||
eventType: 'click',
|
|
||||||
targetEl: tgt.tagName,
|
|
||||||
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
|
|
||||||
metadata: {
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
path: window.location.pathname,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
track({
|
|
||||||
sessionId: sidRef.current,
|
|
||||||
eventType: 'scroll',
|
|
||||||
metadata: {
|
|
||||||
scrollY: window.scrollY,
|
|
||||||
path: window.location.pathname,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageView = () => {
|
const handlePageView = () => {
|
||||||
|
if (!sidRef.current) return;
|
||||||
|
const page = window.location.pathname;
|
||||||
track({
|
track({
|
||||||
sessionId: sidRef.current,
|
sessionId: sidRef.current,
|
||||||
eventType: 'pageview',
|
eventName: 'page_view',
|
||||||
|
page,
|
||||||
metadata: {
|
metadata: {
|
||||||
path: window.location.pathname,
|
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
enum DefinedInteractions {
|
// called for canonical events dispatched via custom events
|
||||||
ADD_TO_CART = 'add_to_cart',
|
const handleDefinedInteraction = (e: Event) => {
|
||||||
PURCHASE = 'purchase',
|
if (!sidRef.current) return;
|
||||||
}
|
const customEvent = e as CustomEvent<{
|
||||||
|
eventName: EventName;
|
||||||
// called when clicking on "Add to Cart" button or "Purchase" button
|
productId?: string;
|
||||||
const handleDefinedInteraction = (
|
metadata?: Record<string, unknown>;
|
||||||
interactionType: DefinedInteractions,
|
}>;
|
||||||
metadata?: Record<string, any>
|
const page = window.location.pathname;
|
||||||
) => {
|
|
||||||
track({
|
track({
|
||||||
sessionId: sidRef.current,
|
sessionId: sidRef.current,
|
||||||
eventType: interactionType,
|
eventName: customEvent.detail.eventName,
|
||||||
metadata: {
|
page,
|
||||||
path: window.location.pathname,
|
productId: customEvent.detail.productId,
|
||||||
...metadata,
|
metadata: customEvent.detail.metadata,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// wait for session to be ready before tracking
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
handlePageView();
|
handlePageView();
|
||||||
document.addEventListener('click', handleClick);
|
document.addEventListener('definedInteraction', handleDefinedInteraction);
|
||||||
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 () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClick);
|
document.removeEventListener('definedInteraction', handleDefinedInteraction);
|
||||||
document.removeEventListener('definedInteraction', (e: Event) => {
|
|
||||||
const customEvent = e as CustomEvent;
|
|
||||||
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
|
|
||||||
});
|
|
||||||
//window.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ready]);
|
||||||
};
|
};
|
||||||
|
|||||||
38
web/src/hooks/useSession.ts
Normal file
38
web/src/hooks/useSession.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
sessionId: string | null;
|
||||||
|
experimentId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSession = () => {
|
||||||
|
const [state, setState] = useState<SessionState>({
|
||||||
|
sessionId: null,
|
||||||
|
experimentId: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/session');
|
||||||
|
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setState({
|
||||||
|
sessionId: data.sessionId || null,
|
||||||
|
experimentId: data.experimentId || null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session fetch error:', err);
|
||||||
|
setState({ sessionId: null, experimentId: null, isLoading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
30
web/src/lib/config.ts
Normal file
30
web/src/lib/config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type Env = z.infer<typeof envSchema>;
|
||||||
|
const envSchema = z.object({
|
||||||
|
STORE_MODE: z.enum(['hotel', 'airline'], {
|
||||||
|
message: 'STORE_MODE must be either "hotel" or "airline"'
|
||||||
|
}),
|
||||||
|
NEXT_PUBLIC_API_BASE: z.string().url({
|
||||||
|
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
|
||||||
|
}),
|
||||||
|
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
|
||||||
|
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// parse and validate env at module load, fail fast with descriptive errors
|
||||||
|
const parseEnv = (): Env => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
STORE_MODE: process.env.STORE_MODE,
|
||||||
|
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
||||||
|
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
||||||
|
throw new Error(`Environment validation failed:\n${errors}`);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config: Env = parseEnv();
|
||||||
91
web/src/lib/events.ts
Normal file
91
web/src/lib/events.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// canonical events for tracking user interactions
|
||||||
|
export type EventName =
|
||||||
|
// navigation & discovery
|
||||||
|
| 'page_view'
|
||||||
|
| 'view_item_page'
|
||||||
|
| 'learn_more_about_item'
|
||||||
|
// cart operations
|
||||||
|
| 'add_item_to_cart'
|
||||||
|
| 'remove_item'
|
||||||
|
| 'checkout_start'
|
||||||
|
| 'purchase_complete'
|
||||||
|
// filtering & search
|
||||||
|
| 'search'
|
||||||
|
| 'filter_for_date'
|
||||||
|
| 'filter_for_amenities'
|
||||||
|
| 'filter_for_price'
|
||||||
|
| 'sort_change'
|
||||||
|
// dwell signals (Ns threshold)
|
||||||
|
| 'hover_over_title'
|
||||||
|
| 'hover_over_paragraph'
|
||||||
|
| 'hover_over_link'
|
||||||
|
| 'hover_over_button'
|
||||||
|
// session
|
||||||
|
| 'session_start';
|
||||||
|
|
||||||
|
export const eventNames: readonly EventName[] = [
|
||||||
|
'page_view',
|
||||||
|
'view_item_page',
|
||||||
|
'learn_more_about_item',
|
||||||
|
'add_item_to_cart',
|
||||||
|
'remove_item',
|
||||||
|
'checkout_start',
|
||||||
|
'purchase_complete',
|
||||||
|
'search',
|
||||||
|
'filter_for_date',
|
||||||
|
'filter_for_amenities',
|
||||||
|
'filter_for_price',
|
||||||
|
'sort_change',
|
||||||
|
'hover_over_title',
|
||||||
|
'hover_over_paragraph',
|
||||||
|
'hover_over_link',
|
||||||
|
'hover_over_button',
|
||||||
|
'session_start',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface EventBase {
|
||||||
|
sessionId: string;
|
||||||
|
experimentId?: string;
|
||||||
|
storeMode: 'hotel' | 'airline';
|
||||||
|
ts: string; // ISO8601
|
||||||
|
page: string;
|
||||||
|
eventName: EventName;
|
||||||
|
productId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// zod schema for runtime validation
|
||||||
|
export const eventBaseSchema = z.object({
|
||||||
|
sessionId: z.string().min(1),
|
||||||
|
experimentId: z.string().optional(),
|
||||||
|
storeMode: z.enum(['hotel', 'airline']),
|
||||||
|
ts: z.string().datetime(), // validates ISO8601
|
||||||
|
page: z.string().min(1),
|
||||||
|
eventName: z.enum([
|
||||||
|
'page_view',
|
||||||
|
'view_item_page',
|
||||||
|
'learn_more_about_item',
|
||||||
|
'add_item_to_cart',
|
||||||
|
'remove_item',
|
||||||
|
'checkout_start',
|
||||||
|
'purchase_complete',
|
||||||
|
'search',
|
||||||
|
'filter_for_date',
|
||||||
|
'filter_for_amenities',
|
||||||
|
'filter_for_price',
|
||||||
|
'sort_change',
|
||||||
|
'hover_over_title',
|
||||||
|
'hover_over_paragraph',
|
||||||
|
'hover_over_link',
|
||||||
|
'hover_over_button',
|
||||||
|
'session_start',
|
||||||
|
]),
|
||||||
|
productId: z.string().optional(),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
userAgent: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EventBaseValidated = z.infer<typeof eventBaseSchema>;
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Kafka, Producer } from 'kafkajs';
|
|
||||||
|
|
||||||
let producer: Producer | null = null;
|
|
||||||
|
|
||||||
const kafka = new Kafka({
|
|
||||||
clientId: 'phantom-web',
|
|
||||||
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getProducer = async (): Promise<Producer> => {
|
|
||||||
if (!producer) {
|
|
||||||
producer = kafka.producer();
|
|
||||||
await producer.connect();
|
|
||||||
}
|
|
||||||
return producer;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendInteractionEvent = async (ev: {
|
|
||||||
sessionId: string;
|
|
||||||
eventType: string;
|
|
||||||
targetEl?: string;
|
|
||||||
targetUrl?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
ts: number;
|
|
||||||
}) => {
|
|
||||||
const p = await getProducer();
|
|
||||||
// add to the metadata
|
|
||||||
await p.send({
|
|
||||||
topic: 'user-interactions',
|
|
||||||
messages: [{
|
|
||||||
key: ev.sessionId,
|
|
||||||
value: JSON.stringify(ev),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const disconnect = async () => {
|
|
||||||
if (producer) {
|
|
||||||
await producer.disconnect();
|
|
||||||
producer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
102
web/src/lib/sessionStore.ts
Normal file
102
web/src/lib/sessionStore.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
type SessionData = {
|
||||||
|
experimentId?: string;
|
||||||
|
startedAt: number;
|
||||||
|
status: 'active' | 'stopped';
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExperimentData = {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'stopped';
|
||||||
|
sessionIds: string[];
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = new Map<string, SessionData>();
|
||||||
|
const experiments = new Map<string, ExperimentData>();
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
key: process.env.AIRTABLE_API_KEY,
|
||||||
|
base: process.env.AIRTABLE_BASE_ID,
|
||||||
|
table: process.env.AIRTABLE_TABLE_NAME || 'Sessions',
|
||||||
|
};
|
||||||
|
|
||||||
|
// sync session to airtable if credentials present
|
||||||
|
const syncToAirtable = async (sid: string, data: SessionData) => {
|
||||||
|
if (!cfg.key || !cfg.base) return; // skip if not configured
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`;
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${cfg.key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fields: {
|
||||||
|
sessionId: sid,
|
||||||
|
experimentId: data.experimentId || '',
|
||||||
|
startedAt: new Date(data.startedAt).toISOString(),
|
||||||
|
status: data.status,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('airtable sync failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSession = (sid: string) => store.get(sid);
|
||||||
|
|
||||||
|
export const createSession = (sid: string) => {
|
||||||
|
const data: SessionData = { startedAt: Date.now(), status: 'active' };
|
||||||
|
store.set(sid, data);
|
||||||
|
syncToAirtable(sid, data); // async fire-and-forget
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setExperiment = (sid: string, expId: string) => {
|
||||||
|
const data = store.get(sid) || createSession(sid);
|
||||||
|
data.experimentId = expId;
|
||||||
|
store.set(sid, data);
|
||||||
|
syncToAirtable(sid, data);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopExperiment = (sid: string) => {
|
||||||
|
const data = store.get(sid);
|
||||||
|
if (data) {
|
||||||
|
data.status = 'stopped';
|
||||||
|
store.set(sid, data);
|
||||||
|
syncToAirtable(sid, data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// experiment-level operations
|
||||||
|
export const createExperiment = (sid: string, expId: string) => {
|
||||||
|
const exp: ExperimentData = {
|
||||||
|
id: expId,
|
||||||
|
status: 'active',
|
||||||
|
sessionIds: [sid],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
experiments.set(expId, exp);
|
||||||
|
setExperiment(sid, expId); // link session to experiment
|
||||||
|
console.log(`experiment ${expId} started with session ${sid}`);
|
||||||
|
return exp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopExperimentById = (expId: string) => {
|
||||||
|
const exp = experiments.get(expId);
|
||||||
|
if (exp) {
|
||||||
|
exp.status = 'stopped';
|
||||||
|
experiments.set(expId, exp);
|
||||||
|
console.log(`experiment ${expId} stopped`);
|
||||||
|
}
|
||||||
|
return exp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExperiment = (expId: string) => experiments.get(expId);
|
||||||
|
|
||||||
|
export const getAllExperiments = () => Array.from(experiments.values());
|
||||||
36
web/src/proxy.ts
Normal file
36
web/src/proxy.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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,25 +1,38 @@
|
|||||||
/* Airline Platform - Sky Blue Theme */
|
/* Airline Platform - Sky Blue Theme */
|
||||||
|
|
||||||
:root[data-mode="airline"] {
|
@layer base {
|
||||||
|
[data-mode="airline"] {
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
--accent-warning: #ff3b30;
|
--accent-warning: #ff3b30;
|
||||||
--accent-primary-hover: #0051d5;
|
--accent-primary-hover: #0051d5;
|
||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
|
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
[data-mode="airline"] {
|
[data-mode="airline"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .btn-primary {
|
[data-mode="airline"] .btn-primary {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary) !important;
|
||||||
color: #ffffff;
|
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 {
|
[data-mode="airline"] .btn-primary:hover {
|
||||||
background-color: var(--accent-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 {
|
[data-mode="airline"] .btn-secondary {
|
||||||
@@ -264,6 +277,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .input-field:focus {
|
[data-mode="airline"] .input-field:focus {
|
||||||
@@ -300,3 +314,8 @@
|
|||||||
[data-mode="airline"] .checkbox-label:hover {
|
[data-mode="airline"] .checkbox-label:hover {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-mode="airline"] .hero-section {
|
||||||
|
background: var(--hero-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Hotel Platform - Action Blue Theme */
|
/* Hotel Platform - Action Blue Theme */
|
||||||
|
|
||||||
:root[data-mode="hotel"] {
|
@layer base {
|
||||||
|
[data-mode="hotel"] {
|
||||||
--accent-primary: #007aff;
|
--accent-primary: #007aff;
|
||||||
--accent-secondary: #4caf50;
|
--accent-secondary: #4caf50;
|
||||||
--accent-warning: #d9534f;
|
--accent-warning: #d9534f;
|
||||||
@@ -8,8 +9,11 @@
|
|||||||
--accent-primary-light: #e6f2ff;
|
--accent-primary-light: #e6f2ff;
|
||||||
--text-accent: #007aff;
|
--text-accent: #007aff;
|
||||||
--bg-tertiary: #f5f5f7;
|
--bg-tertiary: #f5f5f7;
|
||||||
|
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
[data-mode="hotel"] {
|
[data-mode="hotel"] {
|
||||||
--primary-color: var(--accent-primary);
|
--primary-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
@@ -17,10 +21,19 @@
|
|||||||
[data-mode="hotel"] .btn-primary {
|
[data-mode="hotel"] .btn-primary {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: #ffffff;
|
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 {
|
[data-mode="hotel"] .btn-primary:hover {
|
||||||
background-color: var(--accent-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 {
|
[data-mode="hotel"] .btn-secondary {
|
||||||
@@ -398,3 +411,8 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
border-bottom-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