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,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;
};