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:
Daniel Alves Rösel
2025-11-13 18:07:27 +01:00
committed by Daniel Rosel
parent ea11539f7d
commit 707ce032cf
50 changed files with 2862 additions and 447 deletions

View File

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

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
**/.env
**/.venv
**/__pycache__
**/.ipynb_checkpoints/
**/.virtual_documents/
**/__pycache__/
**/.ipynb_checkpoints/

View File

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

99
backend/server/app.py Normal file
View 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)

View 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

View File

@@ -1,4 +1,18 @@
services:
backend:
container_name: "PHANTOM-backend"
build:
context: .
dockerfile: docker/backend.Dockerfile
ports:
- "${BACKEND_PORT:-5000}:5000"
environment:
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
depends_on:
- kafka
restart: unless-stopped
redis:
container_name: "PHANTOM-redis"
build:
@@ -9,6 +23,7 @@ services:
volumes:
- phantom_redis_data:/data
restart: unless-stopped
zookeeper:
container_name: "PHANTOM-zookeeper"
build:

12
docker/backend.Dockerfile Normal file
View 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

View File

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

View File

@@ -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.
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
## How to Run
```sh
# install deps
npm install
# set store mode (hotel or airline)
export STORE_MODE=hotel
# run dev server
npm run dev
```
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
View File

@@ -8,10 +8,10 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"kafkajs": "^2.2.4",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1041,15 +1041,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/kafkajs": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -1616,6 +1607,15 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

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

View 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>
);
}

View 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>;
}

View File

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

View 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>
</>
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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);
}

View 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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@layer base {
:root {
--background: #ffffff;
--foreground: #171717;
@@ -13,6 +14,7 @@
--border-radius: 8px;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
@theme inline {
--color-background: var(--background);
@@ -21,6 +23,7 @@
--font-mono: var(--font-geist-mono);
}
@layer base {
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
@@ -66,7 +69,9 @@ input, select, textarea {
font-size: 1rem;
outline: none;
}
}
@layer components {
.container {
max-width: 1200px;
margin: 0 auto;
@@ -86,13 +91,19 @@ input, select, textarea {
font-size: 1rem;
border-radius: var(--border-radius);
transition: all 0.2s ease;
background-color: #007aff;
color: #ffffff;
border: none;
cursor: pointer;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: #0051d5;
}
.section-spacing {
margin-bottom: var(--spacing-lg);
}
}

View 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>;
}

View File

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

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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]);
};

View File

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

View 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
View 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
View 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>;

View File

@@ -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
View 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
View 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).*)',
],
};

View File

@@ -1,25 +1,38 @@
/* Airline Platform - Sky Blue Theme */
:root[data-mode="airline"] {
@layer base {
[data-mode="airline"] {
--accent-primary: #007aff;
--accent-secondary: #4caf50;
--accent-warning: #ff3b30;
--accent-primary-hover: #0051d5;
--accent-primary-light: #e6f2ff;
--text-accent: #007aff;
--hero-bg: linear-gradient(to bottom, white, #e6f2ff);
}
}
@layer components {
[data-mode="airline"] {
--primary-color: var(--accent-primary);
}
[data-mode="airline"] .btn-primary {
background-color: var(--accent-primary);
color: #ffffff;
background-color: var(--accent-primary) !important;
color: #ffffff !important;
padding: 12px 24px;
font-weight: 600;
font-size: 1rem;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="airline"] .btn-primary:hover {
background-color: var(--accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
[data-mode="airline"] .btn-secondary {
@@ -264,6 +277,7 @@
border-radius: 6px;
padding: 12px;
transition: border-color 0.2s ease;
width: 100%;
}
[data-mode="airline"] .input-field:focus {
@@ -300,3 +314,8 @@
[data-mode="airline"] .checkbox-label:hover {
color: var(--accent-primary);
}
[data-mode="airline"] .hero-section {
background: var(--hero-bg);
}
}

View File

@@ -1,6 +1,7 @@
/* Hotel Platform - Action Blue Theme */
:root[data-mode="hotel"] {
@layer base {
[data-mode="hotel"] {
--accent-primary: #007aff;
--accent-secondary: #4caf50;
--accent-warning: #d9534f;
@@ -8,8 +9,11 @@
--accent-primary-light: #e6f2ff;
--text-accent: #007aff;
--bg-tertiary: #f5f5f7;
--hero-bg: linear-gradient(to bottom, white, #f5f5f5);
}
}
@layer components {
[data-mode="hotel"] {
--primary-color: var(--accent-primary);
}
@@ -17,10 +21,19 @@
[data-mode="hotel"] .btn-primary {
background-color: var(--accent-primary);
color: #ffffff;
padding: 12px 24px;
font-weight: 600;
font-size: 1rem;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
[data-mode="hotel"] .btn-primary:hover {
background-color: var(--accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
[data-mode="hotel"] .btn-secondary {
@@ -398,3 +411,8 @@
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
[data-mode="hotel"] .hero-section {
background: var(--hero-bg);
}
}