mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43: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
63
web/src/hooks/useHoverTracking.ts
Normal file
63
web/src/hooks/useHoverTracking.ts
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
38
web/src/hooks/useSession.ts
Normal file
38
web/src/hooks/useSession.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user