From 707ce032cf8e09ff80cea7d69cc50799995e569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Alves=20R=C3=B6sel?= <60182044+velocitatem@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:07:27 +0100 Subject: [PATCH] 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 --- .env.example | 21 +- .gitignore | 4 +- README.md | 4 + backend/server/app.py | 99 ++ backend/server/requirements.txt | 5 + docker-compose.yml | 15 + docker/backend.Dockerfile | 12 + experiments/data_export.ipynb | 979 +++++++++++++----- paper/src/preamble.tex | 5 +- web/README.md | 83 ++ web/package-lock.json | 22 +- web/package.json | 4 +- web/src/app/admin/experiments/page.tsx | 199 ++++ web/src/app/airline/layout.tsx | 6 + web/src/app/airline/page.tsx | 9 + web/src/app/airline/products/page.tsx | 74 ++ web/src/app/api/admin/experiments/route.ts | 15 + .../app/api/admin/experiments/start/route.ts | 43 + .../app/api/admin/experiments/stop/route.ts | 39 + web/src/app/api/ingest/route.ts | 42 + web/src/app/api/pricing/route.ts | 45 + web/src/app/api/session/route.ts | 46 + web/src/app/api/track/route.ts | 33 - web/src/app/globals.css | 11 + web/src/app/hotel/layout.tsx | 6 + web/src/app/hotel/page.tsx | 9 + web/src/app/hotel/products/page.tsx | 75 ++ .../components/feats/airline/AirlineCard.tsx | 87 ++ .../components/feats/airline/AirlineHero.tsx | 156 +++ web/src/components/feats/hotel/HotelCard.tsx | 98 ++ web/src/components/feats/hotel/HotelHero.tsx | 103 ++ web/src/components/ui/Button.tsx | 20 + web/src/components/ui/DateInput.tsx | 7 + web/src/components/ui/Dropdown.tsx | 83 ++ web/src/components/ui/Input.tsx | 29 + web/src/components/ui/Label.tsx | 13 + web/src/components/ui/Navigation.tsx | 48 + web/src/components/ui/PriceDisplay.tsx | 136 +++ web/src/components/ui/RadioGroup.tsx | 33 + web/src/components/ui/index.ts | 7 + web/src/hooks/useHoverTracking.ts | 63 ++ web/src/hooks/useInteractionTracking.ts | 137 +-- web/src/hooks/useSession.ts | 38 + web/src/lib/config.ts | 30 + web/src/lib/events.ts | 91 ++ web/src/lib/kafka.ts | 42 - web/src/lib/sessionStore.ts | 102 ++ web/src/proxy.ts | 36 + web/src/styles/airline.css | 25 +- web/src/styles/hotel.css | 20 +- 50 files changed, 2862 insertions(+), 447 deletions(-) create mode 100644 backend/server/app.py create mode 100644 backend/server/requirements.txt create mode 100644 docker/backend.Dockerfile create mode 100755 web/src/app/admin/experiments/page.tsx create mode 100644 web/src/app/airline/layout.tsx create mode 100644 web/src/app/airline/page.tsx create mode 100644 web/src/app/airline/products/page.tsx create mode 100644 web/src/app/api/admin/experiments/route.ts create mode 100644 web/src/app/api/admin/experiments/start/route.ts create mode 100644 web/src/app/api/admin/experiments/stop/route.ts create mode 100644 web/src/app/api/ingest/route.ts create mode 100644 web/src/app/api/pricing/route.ts create mode 100644 web/src/app/api/session/route.ts delete mode 100644 web/src/app/api/track/route.ts create mode 100644 web/src/app/hotel/layout.tsx create mode 100644 web/src/app/hotel/page.tsx create mode 100644 web/src/app/hotel/products/page.tsx create mode 100644 web/src/components/feats/airline/AirlineCard.tsx create mode 100644 web/src/components/feats/airline/AirlineHero.tsx create mode 100644 web/src/components/feats/hotel/HotelCard.tsx create mode 100644 web/src/components/feats/hotel/HotelHero.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/DateInput.tsx create mode 100644 web/src/components/ui/Dropdown.tsx create mode 100644 web/src/components/ui/Input.tsx create mode 100644 web/src/components/ui/Label.tsx create mode 100644 web/src/components/ui/Navigation.tsx create mode 100644 web/src/components/ui/PriceDisplay.tsx create mode 100644 web/src/components/ui/RadioGroup.tsx create mode 100644 web/src/components/ui/index.ts create mode 100644 web/src/hooks/useHoverTracking.ts create mode 100644 web/src/hooks/useSession.ts create mode 100644 web/src/lib/config.ts create mode 100644 web/src/lib/events.ts delete mode 100644 web/src/lib/kafka.ts create mode 100644 web/src/lib/sessionStore.ts create mode 100644 web/src/proxy.ts diff --git a/.env.example b/.env.example index 0cba496..c011fd8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,18 @@ -HOSTNAME=localhost +# Network configuration +HOSTNAME=localhost # hostname for service discovery across docker network -# PORTS -KAFKA_PORT=9092 -REDIS_PORT=6377 +# Application configuration +STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme +NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format +NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling +NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions + +# Backend service +BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod) + +# Service ports - used by docker-compose and service communication +BACKEND_PORT=5000 # backend server port for kafka ingestion API +KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com) +KAFKA_PORT=9092 # kafka broker port for event streaming +REDIS_PORT=6377 # redis port for worker queue and caching +REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring diff --git a/.gitignore b/.gitignore index 384891a..cb5cd75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ **/.env **/.venv -**/__pycache__ +**/.virtual_documents/ +**/__pycache__/ **/.ipynb_checkpoints/ -**/.virtual_documents/ \ No newline at end of file diff --git a/README.md b/README.md index ac0a597..76c5e14 100644 --- a/README.md +++ b/README.md @@ -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/ + diff --git a/backend/server/app.py b/backend/server/app.py new file mode 100644 index 0000000..8544689 --- /dev/null +++ b/backend/server/app.py @@ -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) diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt new file mode 100644 index 0000000..d9113ed --- /dev/null +++ b/backend/server/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b0a6521..49223a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..32eb28b --- /dev/null +++ b/docker/backend.Dockerfile @@ -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"] diff --git a/experiments/data_export.ipynb b/experiments/data_export.ipynb index c0620ff..4ba73fb 100644 --- a/experiments/data_export.ipynb +++ b/experiments/data_export.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 98, + "execution_count": 9, "id": "62eafcd9-5462-4063-8873-0e7fb9add907", "metadata": {}, "outputs": [ @@ -12,7 +12,7 @@ "True" ] }, - "execution_count": 98, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -31,7 +31,7 @@ }, { "cell_type": "code", - "execution_count": 86, + "execution_count": 10, "id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f", "metadata": {}, "outputs": [ @@ -40,22 +40,31 @@ "output_type": "stream", "text": [ "\n", - "RangeIndex: 141 entries, 0 to 140\n", - "Data columns (total 10 columns):\n", - " # Column Non-Null Count Dtype \n", - "--- ------ -------------- ----- \n", - " 0 sessionId 141 non-null object \n", - " 1 eventType 141 non-null object \n", - " 2 ts 141 non-null int64 \n", - " 3 targetEl 14 non-null object \n", - " 4 targetUrl 1 non-null object \n", - " 5 metadata_path 141 non-null object \n", - " 6 metadata_referrer 6 non-null object \n", - " 7 metadata_x 14 non-null float64\n", - " 8 metadata_y 14 non-null float64\n", - " 9 metadata_scrollY 121 non-null float64\n", - "dtypes: float64(3), int64(1), object(6)\n", - "memory usage: 11.1+ KB\n" + "RangeIndex: 528 entries, 0 to 527\n", + "Data columns (total 19 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 sessionId 528 non-null object \n", + " 1 eventType 467 non-null object \n", + " 2 ts 528 non-null object \n", + " 3 targetEl 401 non-null object \n", + " 4 eventName 61 non-null object \n", + " 5 page 61 non-null object \n", + " 6 storeMode 61 non-null object \n", + " 7 userAgent 61 non-null object \n", + " 8 productId 21 non-null object \n", + " 9 metadata_path 467 non-null object \n", + " 10 metadata_referrer 82 non-null object \n", + " 11 metadata_x 425 non-null float64\n", + " 12 metadata_y 425 non-null float64\n", + " 13 metadata_event 7 non-null object \n", + " 14 metadata_targetEl 24 non-null object \n", + " 15 metadata_roomType 5 non-null object \n", + " 16 metadata_price 5 non-null float64\n", + " 17 metadata_nights 5 non-null float64\n", + " 18 metadata_targetUrl 4 non-null object \n", + "dtypes: float64(4), object(15)\n", + "memory usage: 78.5+ KB\n" ] } ], @@ -81,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 87, + "execution_count": 11, "id": "f6819a1c-32ab-49c7-845b-5df7bf60f561", "metadata": {}, "outputs": [ @@ -110,20 +119,33 @@ " eventType\n", " ts\n", " targetEl\n", - " targetUrl\n", + " eventName\n", + " page\n", + " storeMode\n", + " userAgent\n", + " productId\n", " metadata_path\n", " metadata_referrer\n", " metadata_x\n", " metadata_y\n", - " metadata_scrollY\n", + " metadata_event\n", + " metadata_targetEl\n", + " metadata_roomType\n", + " metadata_price\n", + " metadata_nights\n", + " metadata_targetUrl\n", " \n", " \n", " \n", " \n", " 0\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " pageview\n", - " 1761226211163\n", + " 1762434924107\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -131,64 +153,87 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", " 1\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " click\n", - " 1761226218090\n", - " MAIN\n", + " 1762434925198\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", " NaN\n", - " 815.0\n", - " 331.0\n", + " 1098.0\n", + " 663.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", " 2\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " click\n", - " 1761226220890\n", + " 1762434925371\n", " MAIN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 1129.0\n", - " 605.0\n", + " 1098.0\n", + " 663.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", " 3\n", - " 1761225843899-qaiwwwyj2o\n", - " click\n", - " 1761226225801\n", - " DIV\n", + " 1762434923440-66hdhq8qicd\n", + " pageview\n", + " 1762437192910\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", - " 532.0\n", - " 545.0\n", " NaN\n", " \n", " \n", " 4\n", - " 1761225843899-qaiwwwyj2o\n", - " click\n", - " 1761226229364\n", - " DIV\n", - " NaN\n", - " /\n", - " NaN\n", - " 481.0\n", - " 399.0\n", - " NaN\n", - " \n", - " \n", - " 5\n", - " 1761227236286-e7mphcvw6t\n", + " 1762434923440-66hdhq8qicd\n", " pageview\n", - " 1761227236426\n", + " 1762437198539\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -196,38 +241,131 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", - " 6\n", - " 1761227236286-e7mphcvw6t\n", + " 390\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", " click\n", - " 1761227239328\n", + " 1762443115648\n", " DIV\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 202.0\n", - " 351.0\n", + " 245.0\n", + " 595.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 7\n", - " 1761227236286-e7mphcvw6t\n", + " 391\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", " click\n", - " 1761227244783\n", - " A\n", - " https://vercel.com/new?utm_source=create-next-...\n", + " 1762443174606\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 377.0\n", - " 723.0\n", + " 475.0\n", + " 428.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 8\n", - " 1761828056433-0gz7aboz86h\n", + " 392\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443183406\n", + " INPUT\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 832.0\n", + " 219.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 393\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443208588\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 485.0\n", + " 155.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 394\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443225474\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 281.0\n", + " 281.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 407\n", + " 1762444018243-0120z6z5u42f\n", " pageview\n", - " 1761828261783\n", + " 1762444018256\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -235,111 +373,381 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", - " 9\n", - " 1761828056433-0gz7aboz86h\n", + " 408\n", + " 1762444018243-0120z6z5u42f\n", " click\n", - " 1761828266484\n", - " H1\n", + " 1762445774344\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", " NaN\n", - " 527.0\n", - " 169.0\n", + " 299.0\n", + " 214.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 10\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270314\n", + " 431\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " pageview\n", + " 1762448190973\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " NaN\n", - " 51.666668\n", " \n", " \n", - " 11\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270328\n", + " 432\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448192425\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", " NaN\n", + " 1623.0\n", + " 493.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", - " 50.000000\n", " \n", " \n", - " 12\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270336\n", + " 433\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448192645\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", " NaN\n", + " 1623.0\n", + " 493.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 434\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " pageview\n", + " 1762448205850\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 435\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448207922\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 421.0\n", + " 216.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 438\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " pageview\n", + " 1762448283244\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 439\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448295524\n", + " HTML\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 614.0\n", + " 720.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 440\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448342763\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 416.0\n", + " 397.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 441\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " pageview\n", + " 1762448343396\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 442\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448829631\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 45.0\n", + " 44.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", - " 49.166668\n", " \n", " \n", "\n", "" ], "text/plain": [ - " sessionId eventType ts targetEl \\\n", - "0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n", - "1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n", - "2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n", - "3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n", - "4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n", - "5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n", - "6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n", - "7 1761227236286-e7mphcvw6t click 1761227244783 A \n", - "8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n", - "9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n", - "10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n", - "11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n", - "12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n", + " sessionId eventType ts targetEl \\\n", + "0 1762434923440-66hdhq8qicd pageview 1762434924107 NaN \n", + "1 1762434923440-66hdhq8qicd click 1762434925198 DIV \n", + "2 1762434923440-66hdhq8qicd click 1762434925371 MAIN \n", + "3 1762434923440-66hdhq8qicd pageview 1762437192910 NaN \n", + "4 1762434923440-66hdhq8qicd pageview 1762437198539 NaN \n", + "390 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443115648 DIV \n", + "391 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443174606 DIV \n", + "392 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443183406 INPUT \n", + "393 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443208588 DIV \n", + "394 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443225474 DIV \n", + "407 1762444018243-0120z6z5u42f pageview 1762444018256 NaN \n", + "408 1762444018243-0120z6z5u42f click 1762445774344 DIV \n", + "431 214d9fad-9b00-40c3-bd0e-7739b6acd654 pageview 1762448190973 NaN \n", + "432 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448192425 DIV \n", + "433 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448192645 DIV \n", + "434 214d9fad-9b00-40c3-bd0e-7739b6acd654 pageview 1762448205850 NaN \n", + "435 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448207922 DIV \n", + "438 f0d40ca6-c1d3-4ecd-beb3-796adc74349d pageview 1762448283244 NaN \n", + "439 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448295524 HTML \n", + "440 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448342763 DIV \n", + "441 f0d40ca6-c1d3-4ecd-beb3-796adc74349d pageview 1762448343396 NaN \n", + "442 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448829631 DIV \n", "\n", - " targetUrl metadata_path \\\n", - "0 NaN / \n", - "1 NaN / \n", - "2 NaN / \n", - "3 NaN / \n", - "4 NaN / \n", - "5 NaN / \n", - "6 NaN / \n", - "7 https://vercel.com/new?utm_source=create-next-... / \n", - "8 NaN / \n", - "9 NaN / \n", - "10 NaN / \n", - "11 NaN / \n", - "12 NaN / \n", + " eventName page storeMode userAgent productId metadata_path \\\n", + "0 NaN NaN NaN NaN NaN / \n", + "1 NaN NaN NaN NaN NaN / \n", + "2 NaN NaN NaN NaN NaN / \n", + "3 NaN NaN NaN NaN NaN / \n", + "4 NaN NaN NaN NaN NaN / \n", + "390 NaN NaN NaN NaN NaN / \n", + "391 NaN NaN NaN NaN NaN / \n", + "392 NaN NaN NaN NaN NaN / \n", + "393 NaN NaN NaN NaN NaN / \n", + "394 NaN NaN NaN NaN NaN / \n", + "407 NaN NaN NaN NaN NaN / \n", + "408 NaN NaN NaN NaN NaN / \n", + "431 NaN NaN NaN NaN NaN / \n", + "432 NaN NaN NaN NaN NaN / \n", + "433 NaN NaN NaN NaN NaN / \n", + "434 NaN NaN NaN NaN NaN / \n", + "435 NaN NaN NaN NaN NaN / \n", + "438 NaN NaN NaN NaN NaN / \n", + "439 NaN NaN NaN NaN NaN / \n", + "440 NaN NaN NaN NaN NaN / \n", + "441 NaN NaN NaN NaN NaN / \n", + "442 NaN NaN NaN NaN NaN / \n", "\n", - " metadata_referrer metadata_x metadata_y metadata_scrollY \n", - "0 NaN NaN NaN \n", - "1 NaN 815.0 331.0 NaN \n", - "2 NaN 1129.0 605.0 NaN \n", - "3 NaN 532.0 545.0 NaN \n", - "4 NaN 481.0 399.0 NaN \n", - "5 NaN NaN NaN \n", - "6 NaN 202.0 351.0 NaN \n", - "7 NaN 377.0 723.0 NaN \n", - "8 NaN NaN NaN \n", - "9 NaN 527.0 169.0 NaN \n", - "10 NaN NaN NaN 51.666668 \n", - "11 NaN NaN NaN 50.000000 \n", - "12 NaN NaN NaN 49.166668 " + " metadata_referrer metadata_x metadata_y metadata_event \\\n", + "0 NaN NaN NaN \n", + "1 NaN 1098.0 663.0 NaN \n", + "2 NaN 1098.0 663.0 NaN \n", + "3 NaN NaN NaN \n", + "4 NaN NaN NaN \n", + "390 NaN 245.0 595.0 NaN \n", + "391 NaN 475.0 428.0 NaN \n", + "392 NaN 832.0 219.0 NaN \n", + "393 NaN 485.0 155.0 NaN \n", + "394 NaN 281.0 281.0 NaN \n", + "407 NaN NaN NaN \n", + "408 NaN 299.0 214.0 NaN \n", + "431 NaN NaN NaN \n", + "432 NaN 1623.0 493.0 NaN \n", + "433 NaN 1623.0 493.0 NaN \n", + "434 NaN NaN NaN \n", + "435 NaN 421.0 216.0 NaN \n", + "438 NaN NaN NaN \n", + "439 NaN 614.0 720.0 NaN \n", + "440 NaN 416.0 397.0 NaN \n", + "441 NaN NaN NaN \n", + "442 NaN 45.0 44.0 NaN \n", + "\n", + " metadata_targetEl metadata_roomType metadata_price metadata_nights \\\n", + "0 NaN NaN NaN NaN \n", + "1 NaN NaN NaN NaN \n", + "2 NaN NaN NaN NaN \n", + "3 NaN NaN NaN NaN \n", + "4 NaN NaN NaN NaN \n", + "390 NaN NaN NaN NaN \n", + "391 NaN NaN NaN NaN \n", + "392 NaN NaN NaN NaN \n", + "393 NaN NaN NaN NaN \n", + "394 NaN NaN NaN NaN \n", + "407 NaN NaN NaN NaN \n", + "408 NaN NaN NaN NaN \n", + "431 NaN NaN NaN NaN \n", + "432 NaN NaN NaN NaN \n", + "433 NaN NaN NaN NaN \n", + "434 NaN NaN NaN NaN \n", + "435 NaN NaN NaN NaN \n", + "438 NaN NaN NaN NaN \n", + "439 NaN NaN NaN NaN \n", + "440 NaN NaN NaN NaN \n", + "441 NaN NaN NaN NaN \n", + "442 NaN NaN NaN NaN \n", + "\n", + " metadata_targetUrl \n", + "0 NaN \n", + "1 NaN \n", + "2 NaN \n", + "3 NaN \n", + "4 NaN \n", + "390 NaN \n", + "391 NaN \n", + "392 NaN \n", + "393 NaN \n", + "394 NaN \n", + "407 NaN \n", + "408 NaN \n", + "431 NaN \n", + "432 NaN \n", + "433 NaN \n", + "434 NaN \n", + "435 NaN \n", + "438 NaN \n", + "439 NaN \n", + "440 NaN \n", + "441 NaN \n", + "442 NaN " ] }, - "execution_count": 87, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -350,19 +758,21 @@ }, { "cell_type": "code", - "execution_count": 88, + "execution_count": 12, "id": "380eca5f-8304-4fb2-be32-e8bcfd312085", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "['1761225843899-qaiwwwyj2o',\n", - " '1761828056433-0gz7aboz86h',\n", - " '1761227236286-e7mphcvw6t']" + "['214d9fad-9b00-40c3-bd0e-7739b6acd654',\n", + " '1762444018243-0120z6z5u42f',\n", + " 'f0d40ca6-c1d3-4ecd-beb3-796adc74349d',\n", + " 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',\n", + " '1762434923440-66hdhq8qicd']" ] }, - "execution_count": 88, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -373,7 +783,7 @@ }, { "cell_type": "code", - "execution_count": 89, + "execution_count": 13, "id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1", "metadata": {}, "outputs": [], @@ -383,14 +793,14 @@ }, { "cell_type": "code", - "execution_count": 101, + "execution_count": 17, "id": "050d90a4-20a9-47f5-b998-c31178a54cb3", "metadata": {}, "outputs": [], "source": [ "def build_transition_prob_matrix(df: pd.DataFrame):\n", - " df = df.dropna(subset=['eventType'])\n", - " events = df['eventType'].tolist()\n", + " df = df.dropna(subset=['eventName'])\n", + " events = df['eventName'].tolist()\n", " labels = pd.Index(events).unique().tolist()\n", " idx = {e:i for i,e in enumerate(labels)}\n", " M = np.zeros((len(labels), len(labels)), dtype=float)\n", @@ -404,7 +814,7 @@ }, { "cell_type": "code", - "execution_count": 107, + "execution_count": 18, "id": "e68f9004-82f5-4826-aece-e3dc6e15a18f", "metadata": {}, "outputs": [], @@ -466,7 +876,7 @@ }, { "cell_type": "code", - "execution_count": 108, + "execution_count": 19, "id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc", "metadata": {}, "outputs": [ @@ -479,41 +889,15 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", - "\n", - "\n", - "\n", - "click\n", - "\n", - "click\n", - "\n", - "\n", - "\n", - "pageview->click\n", - "\n", - "\n", - "1.0\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "1.0\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -523,8 +907,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0. 1.]\n", - " [0. 1.]]\n" + "[]\n" ] }, { @@ -536,75 +919,169 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", + "\n", + "\n", + "\n", "\n", - "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[]\n" + ] + }, + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[]\n" + ] + }, + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "page_view\n", + "\n", + "page_view\n", + "\n", + "\n", "\n", - "pageview->pageview\n", - "\n", - "\n", - "0.2\n", + "page_view->page_view\n", + "\n", + "\n", + "0.70\n", "\n", "\n", "\n", "click\n", - "\n", - "click\n", + "\n", + "click\n", "\n", - "\n", + "\n", "\n", - "pageview->click\n", - "\n", - "\n", - "0.8\n", + "page_view->click\n", + "\n", + "\n", + "0.17\n", "\n", - "\n", - "\n", - "click->pageview\n", - "\n", - "\n", - "0.3\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "0.6\n", - "\n", - "\n", + "\n", "\n", - "scroll\n", - "\n", - "scroll\n", + "product_hover\n", + "\n", + "product_hover\n", "\n", - "\n", + "\n", + "\n", + "page_view->product_hover\n", + "\n", + "\n", + "0.13\n", + "\n", + "\n", + "\n", + "click->page_view\n", + "\n", + "\n", + "0.35\n", + "\n", + "\n", "\n", - "click->scroll\n", - "\n", - "\n", - "0.1\n", + "click->click\n", + "\n", + "\n", + "0.41\n", "\n", - "\n", + "\n", "\n", - "scroll->scroll\n", - "\n", - "\n", - "1.0\n", + "click->product_hover\n", + "\n", + "\n", + "0.24\n", + "\n", + "\n", + "\n", + "product_hover->click\n", + "\n", + "\n", + "0.07\n", + "\n", + "\n", + "\n", + "product_hover->product_hover\n", + "\n", + "\n", + "0.60\n", + "\n", + "\n", + "\n", + "product_view\n", + "\n", + "product_view\n", + "\n", + "\n", + "\n", + "product_hover->product_view\n", + "\n", + "\n", + "0.33\n", + "\n", + "\n", + "\n", + "product_view->click\n", + "\n", + "\n", + "1.00\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -614,9 +1091,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0.25 0.75 0. ]\n", - " [0.28571429 0.57142857 0.14285714]\n", - " [0. 0.00826446 0.99173554]]\n" + "[[0.69565217 0.17391304 0.13043478 0. ]\n", + " [0.35294118 0.41176471 0.23529412 0. ]\n", + " [0. 0.06666667 0.6 0.33333333]\n", + " [0. 1. 0. 0. ]]\n" ] }, { @@ -628,41 +1106,15 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", - "\n", - "\n", - "\n", - "click\n", - "\n", - "click\n", - "\n", - "\n", - "\n", - "pageview->click\n", - "\n", - "\n", - "1.0\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "1.0\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -672,14 +1124,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0. 1.]\n", - " [0. 1.]]\n" + "[]\n" ] } ], "source": [ "def explore_session(session_id: str):\n", - " subset = df[df['sessionId'] == session_id] # not .where(...)\n", + " subset = df[df['sessionId'] == session_id]\n", " P, labels = build_transition_prob_matrix(subset)\n", " g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n", " display(g)\n", diff --git a/paper/src/preamble.tex b/paper/src/preamble.tex index 0acd7c7..79b2857 100644 --- a/paper/src/preamble.tex +++ b/paper/src/preamble.tex @@ -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) diff --git a/web/README.md b/web/README.md index 12fff92..ae759cb 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json index 33bebd6..e773ffb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" + } } } } diff --git a/web/package.json b/web/package.json index 9b83e2d..0a32603 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/admin/experiments/page.tsx b/web/src/app/admin/experiments/page.tsx new file mode 100755 index 0000000..ef8f89e --- /dev/null +++ b/web/src/app/admin/experiments/page.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

loading session...

+
+ ); + } + + return ( +
+
+
+
+

+ Experiments +

+

+ current session: {sessionId || 'none'} +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + {exps.length === 0 ? ( + + + + ) : ( + exps.map((exp) => ( + + + + + + + + )) + )} + +
+ experiment id + + status + + session count + + created + + action +
+ no experiments yet +
+ {exp.id.slice(0, 8)}... + + + {exp.status} + + + {exp.sessionIds.length} + + {new Date(exp.createdAt).toLocaleString()} + + {exp.status === 'active' && ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/app/airline/layout.tsx b/web/src/app/airline/layout.tsx new file mode 100644 index 0000000..a298d7d --- /dev/null +++ b/web/src/app/airline/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; +import '@/styles/airline.css'; + +export default function AirlineLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/web/src/app/airline/page.tsx b/web/src/app/airline/page.tsx new file mode 100644 index 0000000..7499f46 --- /dev/null +++ b/web/src/app/airline/page.tsx @@ -0,0 +1,9 @@ +import AirlineHero from '@/components/feats/airline/AirlineHero'; + +export default function AirlineHome() { + return ( +
+ +
+ ); +} diff --git a/web/src/app/airline/products/page.tsx b/web/src/app/airline/products/page.tsx new file mode 100644 index 0000000..c62e1d1 --- /dev/null +++ b/web/src/app/airline/products/page.tsx @@ -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 ( + <> + +
+

Available Flights

+
+ {flights.map((f) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/app/api/admin/experiments/route.ts b/web/src/app/api/admin/experiments/route.ts new file mode 100644 index 0000000..58fcede --- /dev/null +++ b/web/src/app/api/admin/experiments/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/admin/experiments/start/route.ts b/web/src/app/api/admin/experiments/start/route.ts new file mode 100644 index 0000000..2fb35e3 --- /dev/null +++ b/web/src/app/api/admin/experiments/start/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/admin/experiments/stop/route.ts b/web/src/app/api/admin/experiments/stop/route.ts new file mode 100644 index 0000000..521219b --- /dev/null +++ b/web/src/app/api/admin/experiments/stop/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/ingest/route.ts b/web/src/app/api/ingest/route.ts new file mode 100644 index 0000000..70497ac --- /dev/null +++ b/web/src/app/api/ingest/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/pricing/route.ts b/web/src/app/api/pricing/route.ts new file mode 100644 index 0000000..414d311 --- /dev/null +++ b/web/src/app/api/pricing/route.ts @@ -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); +} diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts new file mode 100644 index 0000000..7f5b4c6 --- /dev/null +++ b/web/src/app/api/session/route.ts @@ -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 } + ); + } +} diff --git a/web/src/app/api/track/route.ts b/web/src/app/api/track/route.ts deleted file mode 100644 index 1ccd720..0000000 --- a/web/src/app/api/track/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index eba0bfc..4a5b0c9 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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); } +} diff --git a/web/src/app/hotel/layout.tsx b/web/src/app/hotel/layout.tsx new file mode 100644 index 0000000..ead9aa1 --- /dev/null +++ b/web/src/app/hotel/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; +import '@/styles/hotel.css'; + +export default function HotelLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/web/src/app/hotel/page.tsx b/web/src/app/hotel/page.tsx new file mode 100644 index 0000000..c614ff5 --- /dev/null +++ b/web/src/app/hotel/page.tsx @@ -0,0 +1,9 @@ +import HotelHero from '@/components/feats/hotel/HotelHero'; + +export default function HotelHome() { + return ( +
+ +
+ ); +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx new file mode 100644 index 0000000..ece120b --- /dev/null +++ b/web/src/app/hotel/products/page.tsx @@ -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 ( + <> + +
+

Available Hotels

+
+ {hotels.map((h) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx new file mode 100644 index 0000000..b08827d --- /dev/null +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -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) => { + 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 ( +
+
+
{flight.departure.time}
+
{flight.departure.airport}
+
+ +
+
{flight.duration}
+
+ {flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`} +
+
+ +
+
{flight.arrival.time}
+
{flight.arrival.airport}
+
+ +
+
{flight.cabinClass}
+
{flight.fareRule}
+ {flight.refundable && ( +
Refundable
+ )} +
+ +
+
+
+ ); +} diff --git a/web/src/components/feats/airline/AirlineHero.tsx b/web/src/components/feats/airline/AirlineHero.tsx new file mode 100644 index 0000000..cca2e45 --- /dev/null +++ b/web/src/components/feats/airline/AirlineHero.tsx @@ -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 = () => ( + + + +); + +const LocationIcon = () => ( + + + + +); + +export default function AirlineHero() { + const [tripType, setTripType] = useState('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 ( +
+
+
+

+ Book flights at the best prices +

+

+ Compare hundreds of airlines and find the perfect flight for your journey +

+
+ +
+
+
+ +
+ +
+
+ + setOrigin(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDestination(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDepartDate(e.target.value)} + required + /> +
+ +
+ + {tripType === 'roundtrip' ? ( + setReturnDate(e.target.value)} + required + /> + ) : ( + + )} +
+
+ +
+
+ + + setPassengers({ ...passengers, adults: v })} + /> + setPassengers({ ...passengers, children: v })} + /> + setPassengers({ ...passengers, infants: v })} + /> + +
+
+ +
+ +
+
+
+ +
+

Direct flights available · Flexible booking · Compare 500+ airlines worldwide

+
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx new file mode 100644 index 0000000..8c68801 --- /dev/null +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -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) => { + 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 = { + wifi: 'Wi-Fi', + pool: 'Pool', + gym: 'Gym', + parking: 'Parking', + breakfast: 'Breakfast', + spa: 'Spa', + }; + return {iconMap[name.toLowerCase()] || name}; +}; + +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 ( +
+
+ Image +
+ +
+

{hotel.name}

+
{hotel.roomType}
+
+ {hotel.checkIn} - {hotel.checkOut} +
+
+ {hotel.amenities.map((a) => ( + + ))} +
+ {hotel.refundable && ( +
Free cancellation
+ )} +
+ +
+
+ +
+
+ Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''} +
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelHero.tsx b/web/src/components/feats/hotel/HotelHero.tsx new file mode 100644 index 0000000..70bd595 --- /dev/null +++ b/web/src/components/feats/hotel/HotelHero.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui'; + +const LocationIcon = () => ( + + + + +); + +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 ( +
+
+
+

+ Find your perfect stay +

+

+ Search hotels, compare prices, and book with confidence +

+
+ +
+
+
+ + setDestination(e.target.value)} + placeholder="City, hotel, or landmark" + icon={} + required + /> +
+ +
+ + setCheckIn(e.target.value)} + required + /> +
+ +
+ + setCheckOut(e.target.value)} + required + /> +
+ +
+ + + setGuests({ ...guests, adults: v })} + /> + setGuests({ ...guests, rooms: v })} + /> + +
+ +
+ +
+
+
+ +
+

Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings

+
+
+
+ ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..a2302a7 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,20 @@ +import { ReactNode, ButtonHTMLAttributes } from 'react'; + +type BtnVariant = 'primary' | 'secondary'; + +interface BtnProps extends ButtonHTMLAttributes { + 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 ( + + ); +} diff --git a/web/src/components/ui/DateInput.tsx b/web/src/components/ui/DateInput.tsx new file mode 100644 index 0000000..f6edd94 --- /dev/null +++ b/web/src/components/ui/DateInput.tsx @@ -0,0 +1,7 @@ +import { InputHTMLAttributes } from 'react'; + +interface DateInpProps extends Omit, 'type'> {} + +export default function DateInput({ className = '', ...props }: DateInpProps) { + return ; +} diff --git a/web/src/components/ui/Dropdown.tsx b/web/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..5a70dd4 --- /dev/null +++ b/web/src/components/ui/Dropdown.tsx @@ -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(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 ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +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 ( +
+
+ {label} + {sublabel && {sublabel}} +
+
+ + {value} + +
+
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..f58c86d --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,29 @@ +import { InputHTMLAttributes, ReactNode } from 'react'; + +interface InpProps extends InputHTMLAttributes { + 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 ( +
+ {icon && ( +
+ {icon} +
+ )} + +
+ ); +} diff --git a/web/src/components/ui/Label.tsx b/web/src/components/ui/Label.tsx new file mode 100644 index 0000000..a404e92 --- /dev/null +++ b/web/src/components/ui/Label.tsx @@ -0,0 +1,13 @@ +import { ReactNode, LabelHTMLAttributes } from 'react'; + +interface LblProps extends LabelHTMLAttributes { + children: ReactNode; +} + +export default function Label({ children, className = '', ...props }: LblProps) { + return ( + + ); +} diff --git a/web/src/components/ui/Navigation.tsx b/web/src/components/ui/Navigation.tsx new file mode 100644 index 0000000..47753d6 --- /dev/null +++ b/web/src/components/ui/Navigation.tsx @@ -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) => { + 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 ( + + {children} + + ); +}; + +export default function Navigation() { + return ( + + ); +} diff --git a/web/src/components/ui/PriceDisplay.tsx b/web/src/components/ui/PriceDisplay.tsx new file mode 100644 index 0000000..b340ab5 --- /dev/null +++ b/web/src/components/ui/PriceDisplay.tsx @@ -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 => { + 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(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ Loading... +
+
+ ); + } + + if (error || !data) { + return ( +
+ Price unavailable +
+ ); + } + + const isStale = isCacheStale(data.cachedAt); + const formattedPrice = formatPrice(data.price, data.currency); + + return ( +
+
+ {formattedPrice} + {perNight && /night} +
+ {isStale && ( + + prices may be outdated + + )} +
+ ); +} diff --git a/web/src/components/ui/RadioGroup.tsx b/web/src/components/ui/RadioGroup.tsx new file mode 100644 index 0000000..e315565 --- /dev/null +++ b/web/src/components/ui/RadioGroup.tsx @@ -0,0 +1,33 @@ +'use client'; + +interface RadioOpt { + value: T; + label: string; +} + +interface RadioGrpProps { + name: string; + options: RadioOpt[]; + value: T; + onChange: (val: T) => void; +} + +export default function RadioGroup({ name, options, value, onChange }: RadioGrpProps) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..d3734cb --- /dev/null +++ b/web/src/components/ui/index.ts @@ -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'; diff --git a/web/src/hooks/useHoverTracking.ts b/web/src/hooks/useHoverTracking.ts new file mode 100644 index 0000000..f0eb4ae --- /dev/null +++ b/web/src/hooks/useHoverTracking.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef } from 'react'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = ( + eventName: EventName, + productId?: string, + metadata?: Record +) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +interface UseHoverTrackingOptions { + eventName: EventName; + productId?: string; + metadata?: Record; + 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(undefined); + const startRef = useRef(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]); +}; diff --git a/web/src/hooks/useInteractionTracking.ts b/web/src/hooks/useInteractionTracking.ts index 317a2c3..563e9ec 100644 --- a/web/src/hooks/useInteractionTracking.ts +++ b/web/src/hooks/useInteractionTracking.ts @@ -1,117 +1,86 @@ -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. - } - return sid; +const fetchSessionId = async (): Promise => { + try { + const res = await fetch('/api/session'); + const data = await res.json(); + return data.sessionId || ''; + } catch (err) { + console.error('failed to fetch session:', err); + return ''; + } }; const track = async (ev: { - sessionId: string; - eventType: string; - targetEl?: string; - targetUrl?: string; - metadata?: Record; + sessionId: string; + eventName: EventName; + page: string; + productId?: string; + metadata?: Record; }) => { - try { - await fetch('/api/track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(ev), - }); - } catch (err) { - console.error('track failed:', err); - } + try { + await fetch('/api/ingest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ev), + }); + } catch (err) { + console.error('track failed:', err); + } }; export const useInteractionTracking = () => { const sidRef = useRef(''); + 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, - }, - }); - }; - - const handleScroll = () => { - track({ - sessionId: sidRef.current, - eventType: 'scroll', - metadata: { - scrollY: window.scrollY, - path: window.location.pathname, - }, - }); - }; + // fetch session id from httpOnly cookie via API + fetchSessionId().then((sid) => { + sidRef.current = sid; + setReady(true); + }); 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 - ) => { + // 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; + }>; + 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]); }; diff --git a/web/src/hooks/useSession.ts b/web/src/hooks/useSession.ts new file mode 100644 index 0000000..d2f48eb --- /dev/null +++ b/web/src/hooks/useSession.ts @@ -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({ + 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; +}; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..ca9664e --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +type Env = z.infer; +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(); diff --git a/web/src/lib/events.ts b/web/src/lib/events.ts new file mode 100644 index 0000000..52f027f --- /dev/null +++ b/web/src/lib/events.ts @@ -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; + 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; diff --git a/web/src/lib/kafka.ts b/web/src/lib/kafka.ts deleted file mode 100644 index f6abea9..0000000 --- a/web/src/lib/kafka.ts +++ /dev/null @@ -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 => { - 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; - 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; - } -}; diff --git a/web/src/lib/sessionStore.ts b/web/src/lib/sessionStore.ts new file mode 100644 index 0000000..769cfd7 --- /dev/null +++ b/web/src/lib/sessionStore.ts @@ -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(); +const experiments = new Map(); + +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()); diff --git a/web/src/proxy.ts b/web/src/proxy.ts new file mode 100644 index 0000000..ec5b281 --- /dev/null +++ b/web/src/proxy.ts @@ -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).*)', + ], +}; diff --git a/web/src/styles/airline.css b/web/src/styles/airline.css index 33f5cb9..564e366 100644 --- a/web/src/styles/airline.css +++ b/web/src/styles/airline.css @@ -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); +} +} diff --git a/web/src/styles/hotel.css b/web/src/styles/hotel.css index 8c67285..b49c6a6 100644 --- a/web/src/styles/hotel.css +++ b/web/src/styles/hotel.css @@ -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); +} +}