mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-06-01 00:53:36 +00:00
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:
committed by
GitHub
parent
7ece6e82cb
commit
37b2099ee0
30
web/src/lib/config.ts
Normal file
30
web/src/lib/config.ts
Normal 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
91
web/src/lib/events.ts
Normal 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>;
|
||||
@@ -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
102
web/src/lib/sessionStore.ts
Normal 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());
|
||||
Reference in New Issue
Block a user