2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)

* chore: cleaning gitignore

* formating and env documentation

* feat: context switching of hotel/airline depndent on env var via middleware

* fixed alignment and building

* wrong file

* prods

* fixed applying style

* better session cookie management

* tentative session storage with maybe using airtable

* migrated api of ingestion

* events and products apge

* fixing build

* 13 create outline for research paper draft (#18)

* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements

* updated outline for paper from issue

* upadted text formating

* event unification

* refactor tracking to ues callbacks instead of refs

* implement a pricing display api with session passing

* moved middleware to proxy according to new changes in Nextjs

* refactoed kafka ingestion to go via backend not web-db

* Refactor docker-compose services to use individual Dockerfiles (#20)

* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* fixing small bugs and adding exepriments to tracking

* added some doc
This commit is contained in:
Daniel Alves Rösel
2025-11-13 18:07:27 +01:00
committed by GitHub
parent 7ece6e82cb
commit 37b2099ee0
50 changed files with 2865 additions and 446 deletions

View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import { useSession } from '@/hooks/useSession';
type Experiment = {
id: string;
status: 'active' | 'stopped';
sessionIds: string[];
createdAt: number;
};
export default function ExperimentsAdmin() {
const { sessionId, isLoading: sessionLoading } = useSession();
const [exps, setExps] = useState<Experiment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchExps = async () => {
try {
const res = await fetch('/api/admin/experiments');
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const data = await res.json();
setExps(data.experiments || []);
} catch (err: any) {
setError(err.message);
}
};
useEffect(() => {
fetchExps();
}, []);
const handleStart = async () => {
if (!sessionId) {
setError('no session available');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/experiments/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'start failed');
}
await fetchExps(); // refresh list
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleStop = async (expId: string) => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/experiments/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ experimentId: expId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'stop failed');
}
await fetchExps(); // refresh list
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (sessionLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<p className="text-zinc-600 dark:text-zinc-400">loading session...</p>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
<div className="mx-auto max-w-5xl">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
Experiments
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
current session: {sessionId || 'none'}
</p>
</div>
<button
onClick={handleStart}
disabled={loading || !sessionId}
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
{loading ? 'starting...' : 'start experiment'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<tr>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
experiment id
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
status
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
session count
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
created
</th>
<th className="px-6 py-3 font-medium text-zinc-900 dark:text-zinc-100">
action
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{exps.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-zinc-500 dark:text-zinc-400"
>
no experiments yet
</td>
</tr>
) : (
exps.map((exp) => (
<tr
key={exp.id}
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<td className="px-6 py-4 font-mono text-xs text-zinc-700 dark:text-zinc-300">
{exp.id.slice(0, 8)}...
</td>
<td className="px-6 py-4">
<span
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
exp.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
}`}
>
{exp.status}
</span>
</td>
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
{exp.sessionIds.length}
</td>
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-300">
{new Date(exp.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4">
{exp.status === 'active' && (
<button
onClick={() => handleStop(exp.id)}
disabled={loading}
className="text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
>
stop
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { ReactNode } from 'react';
import '@/styles/airline.css';
export default function AirlineLayout({ children }: { children: ReactNode }) {
return <div data-mode="airline">{children}</div>;
}

View File

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

View File

@@ -0,0 +1,74 @@
'use client';
import { Navigation } from '@/components/ui';
import AirlineCard from '@/components/feats/airline/AirlineCard';
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
type FareRule = 'flexible' | 'standard' | 'basic';
interface Flight {
id: string;
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabinClass: CabinClass;
fareRule: FareRule;
refundable: boolean;
basePrice: number;
}
const genRandomFlights = (): Flight[] => {
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
return Array.from({ length: 12 }, (_, i) => {
const depHour = Math.floor(Math.random() * 24);
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
const basePrice = Math.floor(
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
Math.random() * 300
);
return {
id: `flt-${i}`,
departure: {
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
airport: airports[Math.floor(Math.random() * airports.length)],
},
arrival: {
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
airport: airports[Math.floor(Math.random() * airports.length)],
},
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
stops,
cabinClass: cabin,
fareRule,
refundable: Math.random() > 0.7,
basePrice,
};
});
};
export default function AirlineProducts() {
const flights = genRandomFlights();
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
<div className="space-y-4">
{flights.map((f) => (
<AirlineCard key={f.id} flight={f} />
))}
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getAllExperiments } from '@/lib/sessionStore';
export async function GET() {
try {
const exps = getAllExperiments();
return NextResponse.json({ experiments: exps });
} catch (err: any) {
console.error('experiments list error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { createExperiment, getSession } from '@/lib/sessionStore';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sessionId } = body;
if (!sessionId) {
return NextResponse.json(
{ error: 'sessionId required' },
{ status: 400 }
);
}
// verify session exists
const session = getSession(sessionId);
if (!session) {
return NextResponse.json(
{ error: 'session not found' },
{ status: 404 }
);
}
// generate and create experiment
const experimentId = randomUUID();
const exp = createExperiment(sessionId, experimentId);
return NextResponse.json({
experimentId: exp.id,
sessionId,
status: exp.status,
createdAt: exp.createdAt,
});
} catch (err: any) {
console.error('experiment start error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { experimentId } = body;
if (!experimentId) {
return NextResponse.json(
{ error: 'experimentId required' },
{ status: 400 }
);
}
// verify experiment exists
const existing = getExperiment(experimentId);
if (!existing) {
return NextResponse.json(
{ error: 'experiment not found' },
{ status: 404 }
);
}
// stop the experiment
const exp = stopExperimentById(experimentId);
return NextResponse.json({
experimentId: exp!.id,
status: exp!.status,
});
} catch (err: any) {
console.error('experiment stop error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import type { EventBase } from '@/lib/events';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const storeMode = process.env.STORE_MODE || 'hotel';
const userAgent = req.headers.get('user-agent') || undefined;
const event: EventBase = {
...body,
storeMode,
userAgent,
ts: body.ts || new Date().toISOString(),
};
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
if (!res.ok) {
throw new Error(`Backend returned ${res.status}`);
}
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
console.log('[ingest]', event);
}
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('[ingest error]', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
interface PricingResponse {
price: number;
currency: string;
cachedAt: string;
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const productId = searchParams.get('productId');
const sessionId = searchParams.get('sessionId');
const experimentId = searchParams.get('experimentId');
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
// log in dev
if (process.env.NODE_ENV === 'development') {
console.log('[pricing-api]', {
productId,
sessionId,
experimentId,
storeMode,
timestamp: new Date().toISOString(),
});
}
if (!productId) {
return NextResponse.json(
{ error: 'productId is required' },
{ status: 400 }
);
}
// stub: call external pricing provider (random for now)
const basePrice = 100 + Math.random() * 900; // 100-1000 range
const price = Math.round(basePrice * 100) / 100;
const response: PricingResponse = {
price,
currency: 'EUR',
cachedAt: new Date().toISOString(),
};
return NextResponse.json(response);
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { getSession, createSession } from '@/lib/sessionStore';
const COOKIE_NAME = 'phantom_session_id';
const isProd = process.env.NODE_ENV === 'production';
export async function GET(req: NextRequest) {
try {
// check for existing session cookie
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
if (existingSession) {
const sessionData = getSession(existingSession);
return NextResponse.json({
sessionId: existingSession,
experimentId: sessionData?.experimentId,
});
}
// mint new session id
const sessionId = randomUUID();
createSession(sessionId);
const res = NextResponse.json({ sessionId, experimentId: undefined });
// set httpOnly cookie with security flags
res.cookies.set({
name: COOKIE_NAME,
value: sessionId,
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
});
return res;
} catch (err: any) {
console.error('session error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -1,33 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendInteractionEvent } from '@/lib/kafka';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
if (!sessionId || !eventType) {
return NextResponse.json(
{ error: 'sessionId and eventType required' },
{ status: 400 }
);
}
await sendInteractionEvent({
sessionId,
eventType,
targetEl,
targetUrl,
metadata,
ts: Date.now(),
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('track error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

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

View File

@@ -0,0 +1,6 @@
import { ReactNode } from 'react';
import '@/styles/hotel.css';
export default function HotelLayout({ children }: { children: ReactNode }) {
return <div data-mode="hotel">{children}</div>;
}

View File

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

View File

@@ -0,0 +1,75 @@
'use client';
import { Navigation } from '@/components/ui';
import HotelCard from '@/components/feats/hotel/HotelCard';
interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
}
const genRandomHotels = (): Hotel[] => {
const names = [
'Grand Plaza Hotel',
'Seaside Resort',
'Downtown Suites',
'Mountain View Lodge',
'City Center Inn',
'Luxury Beach Resort',
'Urban Boutique Hotel',
'Garden View Hotel',
];
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
return Array.from({ length: 10 }, (_, i) => {
const nights = Math.floor(Math.random() * 5) + 1;
const basePrice = Math.floor(80 + Math.random() * 220);
const selectedAmenities = amenities
.sort(() => Math.random() - 0.5)
.slice(0, Math.floor(Math.random() * 3) + 2);
const today = new Date();
const checkInDate = new Date(today);
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
const checkOutDate = new Date(checkInDate);
checkOutDate.setDate(checkInDate.getDate() + nights);
return {
id: `htl-${i}`,
name: names[i % names.length],
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
amenities: selectedAmenities,
refundable: Math.random() > 0.5,
pricePerNight: basePrice,
nights,
};
});
};
export default function HotelProducts() {
const hotels = genRandomHotels();
return (
<>
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
<div className="space-y-4">
{hotels.map((h) => (
<HotelCard key={h.id} hotel={h} />
))}
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import type { EventName } from '@/lib/events';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
type FareRule = 'flexible' | 'standard' | 'basic';
interface Flight {
id: string;
departure: { time: string; airport: string };
arrival: { time: string; airport: string };
duration: string;
stops: number;
cabinClass: CabinClass;
fareRule: FareRule;
refundable: boolean;
basePrice: number;
}
export default function AirlineCard({ flight }: { flight: Flight }) {
const durationRef = useHoverTracking({
eventName: 'hover_over_title',
productId: flight.id,
metadata: { elementText: flight.duration },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: flight.id,
metadata: { elementText: 'price' },
});
const handleCardClick = () => {
dispatchInteraction('view_item_page', flight.id, {
cabinClass: flight.cabinClass,
fareRule: flight.fareRule,
price: flight.basePrice,
});
};
return (
<div
className="flight-card cursor-pointer"
onClick={handleCardClick}
>
<div className="flight-timing">
<div className="flight-time">{flight.departure.time}</div>
<div className="flight-airport">{flight.departure.airport}</div>
</div>
<div className="flight-route">
<div ref={durationRef} className="flight-duration">{flight.duration}</div>
<div className="flight-stops">
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
</div>
</div>
<div className="flight-timing">
<div className="flight-time">{flight.arrival.time}</div>
<div className="flight-airport">{flight.arrival.airport}</div>
</div>
<div className="flight-pricing">
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
{flight.refundable && (
<div className="badge-value text-xs mb-2">Refundable</div>
)}
<div ref={priceRef}>
<PriceDisplay
productId={flight.id}
className="fare-price"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useState, FormEvent } from 'react';
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
type TripType = 'roundtrip' | 'oneway' | 'multicity';
const PlaneIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
);
const LocationIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function AirlineHero() {
const [tripType, setTripType] = useState<TripType>('roundtrip');
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [departDate, setDepartDate] = useState('');
const [returnDate, setReturnDate] = useState('');
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
const handleSearch = (e: FormEvent) => {
e.preventDefault();
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
};
const totalPax = passengers.adults + passengers.children + passengers.infants;
return (
<div className="hero-section min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-5xl px-4">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Book flights at the best prices
</h1>
<p className="text-lg">
Compare hundreds of airlines and find the perfect flight for your journey
</p>
</div>
<div className="search-form">
<form onSubmit={handleSearch}>
<div className="mb-6">
<RadioGroup
name="tripType"
value={tripType}
onChange={setTripType}
options={[
{ value: 'roundtrip', label: 'Round-trip' },
{ value: 'oneway', label: 'One-way' },
{ value: 'multicity', label: 'Multi-city' },
]}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label htmlFor="origin">From</Label>
<Input
type="text"
id="origin"
value={origin}
onChange={(e) => setOrigin(e.target.value)}
placeholder="Airport or city"
icon={<PlaneIcon />}
required
/>
</div>
<div>
<Label htmlFor="destination">To</Label>
<Input
type="text"
id="destination"
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="Airport or city"
icon={<LocationIcon />}
required
/>
</div>
<div>
<Label htmlFor="departDate">Departure</Label>
<DateInput
id="departDate"
value={departDate}
onChange={(e) => setDepartDate(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="returnDate">Return</Label>
{tripType === 'roundtrip' ? (
<DateInput
id="returnDate"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
required
/>
) : (
<DateInput id="returnDate" disabled />
)}
</div>
</div>
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
<div className="sm:col-span-1 lg:col-span-1">
<Label htmlFor="passengers">Passengers</Label>
<Dropdown label={`${totalPax} ${totalPax === 1 ? 'passenger' : 'passengers'}`}>
<DropdownCounter
label="Adults"
sublabel="12+ years"
value={passengers.adults}
min={1}
onChange={(v) => setPassengers({ ...passengers, adults: v })}
/>
<DropdownCounter
label="Children"
sublabel="2-11 years"
value={passengers.children}
onChange={(v) => setPassengers({ ...passengers, children: v })}
/>
<DropdownCounter
label="Infants"
sublabel="Under 2"
value={passengers.infants}
onChange={(v) => setPassengers({ ...passengers, infants: v })}
/>
</Dropdown>
</div>
</div>
<div className="mt-6">
<Button type="submit" fullWidth>
Search Flights
</Button>
</div>
</form>
</div>
<div className="mt-6 text-center text-sm">
<p>Direct flights available · Flexible booking · Compare 500+ airlines worldwide</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import type { EventName } from '@/lib/events';
import { useHoverTracking } from '@/hooks/useHoverTracking';
import PriceDisplay from '@/components/ui/PriceDisplay';
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
interface Hotel {
id: string;
name: string;
roomType: string;
checkIn: string;
checkOut: string;
amenities: string[];
refundable: boolean;
pricePerNight: number;
nights: number;
}
const AmenityIcon = ({ name }: { name: string }) => {
const iconMap: Record<string, string> = {
wifi: 'Wi-Fi',
pool: 'Pool',
gym: 'Gym',
parking: 'Parking',
breakfast: 'Breakfast',
spa: 'Spa',
};
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
};
export default function HotelCard({ hotel }: { hotel: Hotel }) {
const titleRef = useHoverTracking({
eventName: 'hover_over_title',
productId: hotel.id,
metadata: { elementText: hotel.name },
});
const priceRef = useHoverTracking({
eventName: 'hover_over_paragraph',
productId: hotel.id,
metadata: { elementText: 'price' },
});
const handleCardClick = () => {
dispatchInteraction('view_item_page', hotel.id, {
roomType: hotel.roomType,
price: hotel.pricePerNight,
nights: hotel.nights,
});
};
return (
<div
className="hotel-card cursor-pointer"
onClick={handleCardClick}
>
<div className="hotel-image bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-sm">Image</span>
</div>
<div className="hotel-info">
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
<div className="text-sm text-[var(--text-secondary)] mb-2">
{hotel.checkIn} - {hotel.checkOut}
</div>
<div className="hotel-features">
{hotel.amenities.map((a) => (
<AmenityIcon key={a} name={a} />
))}
</div>
{hotel.refundable && (
<div className="free-cancellation mt-2">Free cancellation</div>
)}
</div>
<div className="hotel-pricing">
<div ref={priceRef}>
<PriceDisplay
productId={hotel.id}
className="price-wrapper"
perNight
/>
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState, FormEvent } from 'react';
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
const LocationIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function HotelHero() {
const [destination, setDestination] = useState('');
const [checkIn, setCheckIn] = useState('');
const [checkOut, setCheckOut] = useState('');
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
const handleSearch = (e: FormEvent) => {
e.preventDefault();
console.log({ destination, checkIn, checkOut, guests });
};
return (
<div className="hero-section min-h-[70vh] flex items-center justify-center">
<div className="w-full max-w-4xl px-4">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Find your perfect stay
</h1>
<p className="text-lg">
Search hotels, compare prices, and book with confidence
</p>
</div>
<form onSubmit={handleSearch} className="search-form">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="sm:col-span-2">
<Label htmlFor="destination">Where to?</Label>
<Input
type="text"
id="destination"
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="City, hotel, or landmark"
icon={<LocationIcon />}
required
/>
</div>
<div>
<Label htmlFor="checkIn">Check-in</Label>
<DateInput
id="checkIn"
value={checkIn}
onChange={(e) => setCheckIn(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="checkOut">Check-out</Label>
<DateInput
id="checkOut"
value={checkOut}
onChange={(e) => setCheckOut(e.target.value)}
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-4">
<Label htmlFor="guests">Guests & Rooms</Label>
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
<DropdownCounter
label="Adults"
value={guests.adults}
min={1}
onChange={(v) => setGuests({ ...guests, adults: v })}
/>
<DropdownCounter
label="Rooms"
value={guests.rooms}
min={1}
onChange={(v) => setGuests({ ...guests, rooms: v })}
/>
</Dropdown>
</div>
<div className="sm:col-span-2 lg:col-span-4">
<Button type="submit" fullWidth>
Search Hotels
</Button>
</div>
</div>
</form>
<div className="mt-6 text-center text-sm">
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
type BtnVariant = 'primary' | 'secondary';
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: BtnVariant;
children: ReactNode;
fullWidth?: boolean;
}
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
const widthClass = fullWidth ? 'w-full' : '';
return (
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
{children}
</button>
);
}

View File

@@ -0,0 +1,7 @@
import { InputHTMLAttributes } from 'react';
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
export default function DateInput({ className = '', ...props }: DateInpProps) {
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
}

View File

@@ -0,0 +1,83 @@
'use client';
import { ReactNode, useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
children: ReactNode;
}
export default function Dropdown({ label, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="input-field flex justify-between items-center w-full"
>
<span>{label}</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
{children}
</div>
)}
</div>
);
}
interface CounterProps {
label: string;
sublabel?: string;
value: number;
min?: number;
max?: number;
onChange: (val: number) => void;
}
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
return (
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{label}</span>
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
disabled={value <= min}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
</button>
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
disabled={value >= max}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
+
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { InputHTMLAttributes, ReactNode } from 'react';
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode;
}
export default function Input({ icon, className = '', style, ...props }: InpProps) {
const padClass = icon ? 'pl-10' : '';
// Fallback if a custom CSS rule still overrides Tailwind
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
return (
<div className="relative">
{icon && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
>
{icon}
</div>
)}
<input
className={`input-field ${className} ${padClass}`}
style={mergedStyle}
{...props}
/>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode, LabelHTMLAttributes } from 'react';
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
export default function Label({ children, className = '', ...props }: LblProps) {
return (
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
{children}
</label>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, metadata },
});
document.dispatchEvent(e);
};
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
const path = usePathname();
const isActive = path === href;
return (
<Link
href={href}
className={`px-4 py-2 rounded-md transition-colors ${
isActive
? 'bg-[var(--accent-primary)] text-white font-semibold'
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
}`}
>
{children}
</Link>
);
};
export default function Navigation() {
return (
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center space-x-1">
<NavLink href="/">Home</NavLink>
<NavLink href="/products">Products</NavLink>
<NavLink href="/search">Search</NavLink>
<NavLink href="/cart">Cart</NavLink>
<NavLink href="/checkout">Checkout</NavLink>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
import { useEffect, useState, useRef } from 'react';
interface PriceDisplayProps {
productId: string;
className?: string;
perNight?: boolean;
}
interface PricingData {
price: number;
currency: string;
cachedAt: string;
}
interface SessionData {
sessionId: string;
experimentId?: string;
}
const fetchSession = async (): Promise<SessionData> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return {
sessionId: data.sessionId || '',
experimentId: data.experimentId || '',
};
} catch (err) {
console.error('failed to fetch session:', err);
return { sessionId: '', experimentId: '' };
}
};
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('en-US', { // like an std localization
style: 'currency',
currency,
}).format(price);
};
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
const cacheTime = new Date(cachedAt).getTime();
const now = Date.now();
return now - cacheTime > thresholdMs;
};
export default function PriceDisplay({
productId,
className = '',
perNight = false,
}: PriceDisplayProps) {
const sessionRef = useRef<SessionData | null>(null);
const [data, setData] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initAndFetch = async () => {
setLoading(true);
setError(null);
try {
// fetch session if not already loaded
if (!sessionRef.current) {
sessionRef.current = await fetchSession();
}
const { sessionId, experimentId } = sessionRef.current;
if (!sessionId) {
setError('Invalid session');
setLoading(false);
return;
}
const params = new URLSearchParams({
productId,
sessionId,
experimentId: experimentId || '',
});
const res = await fetch(`/api/pricing?${params.toString()}`);
if (!res.ok) {
throw new Error(`Failed to fetch price: ${res.status}`);
}
const pricingData: PricingData = await res.json();
setData(pricingData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
initAndFetch();
}, [productId]);
if (loading) {
return (
<div className={`price-loading ${className}`}>
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
if (error || !data) {
return (
<div className={`price-error ${className}`}>
<span className="text-red-500 text-sm">Price unavailable</span>
</div>
);
}
const isStale = isCacheStale(data.cachedAt);
const formattedPrice = formatPrice(data.price, data.currency);
return (
<div className={`price-display ${className}`}>
<div className="price-amount">
{formattedPrice}
{perNight && <span className="text-xs ml-1">/night</span>}
</div>
{isStale && (
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
prices may be outdated
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
interface RadioOpt<T extends string> {
value: T;
label: string;
}
interface RadioGrpProps<T extends string> {
name: string;
options: RadioOpt<T>[];
value: T;
onChange: (val: T) => void;
}
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
return (
<div className="flex gap-4">
{options.map((opt) => (
<label key={opt.value} className="flex items-center cursor-pointer">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value as T)}
className="mr-2"
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { default as Button } from './Button';
export { default as Label } from './Label';
export { default as Input } from './Input';
export { default as DateInput } from './DateInput';
export { default as RadioGroup } from './RadioGroup';
export { default as Dropdown, DropdownCounter } from './Dropdown';
export { default as Navigation } from './Navigation';

View File

@@ -0,0 +1,63 @@
import { useCallback, useRef } from 'react';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (
eventName: EventName,
productId?: string,
metadata?: Record<string, unknown>
) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, productId, metadata },
});
document.dispatchEvent(e);
};
interface UseHoverTrackingOptions {
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD
}
export const useHoverTracking = (options: UseHoverTrackingOptions) => {
const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD
? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10)
: 1500;
const { eventName, productId, metadata, threshold = defaultThreshold } = options;
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const startRef = useRef<number | undefined>(undefined);
return useCallback((node: HTMLElement | null) => {
if (!node) {
if (timerRef.current) clearTimeout(timerRef.current);
return;
}
const onEnter = () => {
startRef.current = Date.now();
timerRef.current = setTimeout(() => {
const dwellTime = Date.now() - startRef.current!;
dispatchInteraction(eventName, productId, {
...metadata,
dwellTime,
});
}, threshold);
};
const onLeave = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
node.addEventListener('mouseenter', onEnter);
node.addEventListener('mouseleave', onLeave);
return () => {
node.removeEventListener('mouseenter', onEnter);
node.removeEventListener('mouseleave', onLeave);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [eventName, productId, metadata, threshold]);
};

View File

@@ -1,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<string> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return data.sessionId || '';
} catch (err) {
console.error('failed to fetch session:', err);
return '';
}
};
const track = async (ev: {
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
sessionId: string;
eventName: EventName;
page: string;
productId?: string;
metadata?: Record<string, unknown>;
}) => {
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<string>('');
const [ready, setReady] = useState(false);
useEffect(() => {
sidRef.current = genSessionId();
const handleClick = (e: MouseEvent) => {
const tgt = e.target as HTMLElement;
track({
sessionId: sidRef.current,
eventType: 'click',
targetEl: tgt.tagName,
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
metadata: {
x: e.clientX,
y: e.clientY,
path: window.location.pathname,
},
});
};
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<string, any>
) => {
// called for canonical events dispatched via custom events
const handleDefinedInteraction = (e: Event) => {
if (!sidRef.current) return;
const customEvent = e as CustomEvent<{
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
}>;
const page = window.location.pathname;
track({
sessionId: sidRef.current,
eventType: interactionType,
metadata: {
path: window.location.pathname,
...metadata,
},
eventName: customEvent.detail.eventName,
page,
productId: customEvent.detail.productId,
metadata: customEvent.detail.metadata,
});
};
// wait for session to be ready before tracking
if (!ready) return;
handlePageView();
document.addEventListener('click', handleClick);
document.addEventListener('definedInteraction', (e: Event) => {
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
// TOO NOISY: enable if needed but tbh not worth it
//window.addEventListener('scroll', handleScroll, { passive: true });
document.addEventListener('definedInteraction', handleDefinedInteraction);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('definedInteraction', (e: Event) => {
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
//window.removeEventListener('scroll', handleScroll);
document.removeEventListener('definedInteraction', handleDefinedInteraction);
};
}, []);
}, [ready]);
};

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
type SessionState = {
sessionId: string | null;
experimentId: string | null;
isLoading: boolean;
};
export const useSession = () => {
const [state, setState] = useState<SessionState>({
sessionId: null,
experimentId: null,
isLoading: true,
});
useEffect(() => {
const fetchSession = async () => {
try {
const res = await fetch('/api/session');
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const data = await res.json();
setState({
sessionId: data.sessionId || null,
experimentId: data.experimentId || null,
isLoading: false,
});
} catch (err) {
console.error('session fetch error:', err);
setState({ sessionId: null, experimentId: null, isLoading: false });
}
};
fetchSession();
}, []);
return state;
};

30
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
type Env = z.infer<typeof envSchema>;
const envSchema = z.object({
STORE_MODE: z.enum(['hotel', 'airline'], {
message: 'STORE_MODE must be either "hotel" or "airline"'
}),
NEXT_PUBLIC_API_BASE: z.string().url({
message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)'
}),
NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], {
message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"'
}),
});
// parse and validate env at module load, fail fast with descriptive errors
const parseEnv = (): Env => {
const result = envSchema.safeParse({
STORE_MODE: process.env.STORE_MODE,
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
});
if (!result.success) {
const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
return result.data;
};
export const config: Env = parseEnv();

91
web/src/lib/events.ts Normal file
View File

@@ -0,0 +1,91 @@
import { z } from 'zod';
// canonical events for tracking user interactions
export type EventName =
// navigation & discovery
| 'page_view'
| 'view_item_page'
| 'learn_more_about_item'
// cart operations
| 'add_item_to_cart'
| 'remove_item'
| 'checkout_start'
| 'purchase_complete'
// filtering & search
| 'search'
| 'filter_for_date'
| 'filter_for_amenities'
| 'filter_for_price'
| 'sort_change'
// dwell signals (Ns threshold)
| 'hover_over_title'
| 'hover_over_paragraph'
| 'hover_over_link'
| 'hover_over_button'
// session
| 'session_start';
export const eventNames: readonly EventName[] = [
'page_view',
'view_item_page',
'learn_more_about_item',
'add_item_to_cart',
'remove_item',
'checkout_start',
'purchase_complete',
'search',
'filter_for_date',
'filter_for_amenities',
'filter_for_price',
'sort_change',
'hover_over_title',
'hover_over_paragraph',
'hover_over_link',
'hover_over_button',
'session_start',
] as const;
export interface EventBase {
sessionId: string;
experimentId?: string;
storeMode: 'hotel' | 'airline';
ts: string; // ISO8601
page: string;
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
userAgent?: string;
}
// zod schema for runtime validation
export const eventBaseSchema = z.object({
sessionId: z.string().min(1),
experimentId: z.string().optional(),
storeMode: z.enum(['hotel', 'airline']),
ts: z.string().datetime(), // validates ISO8601
page: z.string().min(1),
eventName: z.enum([
'page_view',
'view_item_page',
'learn_more_about_item',
'add_item_to_cart',
'remove_item',
'checkout_start',
'purchase_complete',
'search',
'filter_for_date',
'filter_for_amenities',
'filter_for_price',
'sort_change',
'hover_over_title',
'hover_over_paragraph',
'hover_over_link',
'hover_over_button',
'session_start',
]),
productId: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
userAgent: z.string().optional(),
});
export type EventBaseValidated = z.infer<typeof eventBaseSchema>;

View File

@@ -1,42 +0,0 @@
import { Kafka, Producer } from 'kafkajs';
let producer: Producer | null = null;
const kafka = new Kafka({
clientId: 'phantom-web',
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
});
export const getProducer = async (): Promise<Producer> => {
if (!producer) {
producer = kafka.producer();
await producer.connect();
}
return producer;
};
export const sendInteractionEvent = async (ev: {
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
ts: number;
}) => {
const p = await getProducer();
// add to the metadata
await p.send({
topic: 'user-interactions',
messages: [{
key: ev.sessionId,
value: JSON.stringify(ev),
}],
});
};
export const disconnect = async () => {
if (producer) {
await producer.disconnect();
producer = null;
}
};

102
web/src/lib/sessionStore.ts Normal file
View File

@@ -0,0 +1,102 @@
type SessionData = {
experimentId?: string;
startedAt: number;
status: 'active' | 'stopped';
};
type ExperimentData = {
id: string;
status: 'active' | 'stopped';
sessionIds: string[];
createdAt: number;
};
const store = new Map<string, SessionData>();
const experiments = new Map<string, ExperimentData>();
const cfg = {
key: process.env.AIRTABLE_API_KEY,
base: process.env.AIRTABLE_BASE_ID,
table: process.env.AIRTABLE_TABLE_NAME || 'Sessions',
};
// sync session to airtable if credentials present
const syncToAirtable = async (sid: string, data: SessionData) => {
if (!cfg.key || !cfg.base) return; // skip if not configured
try {
const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`;
await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${cfg.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fields: {
sessionId: sid,
experimentId: data.experimentId || '',
startedAt: new Date(data.startedAt).toISOString(),
status: data.status,
},
}),
});
} catch (err) {
console.error('airtable sync failed:', err);
}
};
export const getSession = (sid: string) => store.get(sid);
export const createSession = (sid: string) => {
const data: SessionData = { startedAt: Date.now(), status: 'active' };
store.set(sid, data);
syncToAirtable(sid, data); // async fire-and-forget
return data;
};
export const setExperiment = (sid: string, expId: string) => {
const data = store.get(sid) || createSession(sid);
data.experimentId = expId;
store.set(sid, data);
syncToAirtable(sid, data);
return data;
};
export const stopExperiment = (sid: string) => {
const data = store.get(sid);
if (data) {
data.status = 'stopped';
store.set(sid, data);
syncToAirtable(sid, data);
}
return data;
};
// experiment-level operations
export const createExperiment = (sid: string, expId: string) => {
const exp: ExperimentData = {
id: expId,
status: 'active',
sessionIds: [sid],
createdAt: Date.now(),
};
experiments.set(expId, exp);
setExperiment(sid, expId); // link session to experiment
console.log(`experiment ${expId} started with session ${sid}`);
return exp;
};
export const stopExperimentById = (expId: string) => {
const exp = experiments.get(expId);
if (exp) {
exp.status = 'stopped';
experiments.set(expId, exp);
console.log(`experiment ${expId} stopped`);
}
return exp;
};
export const getExperiment = (expId: string) => experiments.get(expId);
export const getAllExperiments = () => Array.from(experiments.values());

36
web/src/proxy.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
const mode = process.env.STORE_MODE;
const { pathname } = req.nextUrl;
// skip rewrites for api routes, admin routes, static files, and next internals
if (
pathname.startsWith('/api') ||
pathname.startsWith('/admin') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/static') ||
pathname.includes('.')
// TODO: add robots.txt and sitemap.xml if needed here
) {
return NextResponse.next();
}
// already prefixed with mode
if (pathname.startsWith(`/${mode}`)) {
return NextResponse.next();
}
// rewrite root and unprefixed paths to mode-specific route group
const url = req.nextUrl.clone();
url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: [
// match all paths except those starting with _next/static, _next/image, favicon.ico
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

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

View File

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