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