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

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());