fix(api): add validation for listing and seller in bid route feat(telemetry): add userId to MarketTelemetry style(globe): add null check for canvas element chore: add lint:fix script to package.json
362 lines
8.3 KiB
TypeScript
362 lines
8.3 KiB
TypeScript
/**
|
|
* Classe para tracking de telemetria no marketplace
|
|
* Gerencia eventos client-side com debouncing e batch sending
|
|
*/
|
|
|
|
type TelemetryEventType =
|
|
| "ad_view"
|
|
| "ad_click"
|
|
| "ad_exit"
|
|
| "search"
|
|
| "filter"
|
|
| "favorite"
|
|
| "unfavorite"
|
|
| "purchase"
|
|
| "purchase_intention_created"
|
|
| "purchase_intention_viewed"
|
|
| "bid_placed"
|
|
| "bid_viewed"
|
|
| "seller_profile_viewed"
|
|
| "inquiry"
|
|
| "page_view"
|
|
| "price_change";
|
|
|
|
interface TelemetryEvent {
|
|
event_type: TelemetryEventType;
|
|
ad_id?: number | null;
|
|
session_id?: string;
|
|
metadata?: Record<string, any>;
|
|
}
|
|
|
|
interface PendingEvent extends TelemetryEvent {
|
|
timestamp: number;
|
|
}
|
|
|
|
export class MarketTelemetry {
|
|
private sessionId: string;
|
|
private userId?: number;
|
|
private eventQueue: PendingEvent[] = [];
|
|
private viewStartTimes: Map<number, number> = new Map();
|
|
private batchInterval: NodeJS.Timeout | null = null;
|
|
private readonly BATCH_INTERVAL_MS = 30000; // 30 segundos
|
|
private readonly MAX_BATCH_SIZE = 100;
|
|
|
|
constructor(userId?: number) {
|
|
this.userId = userId;
|
|
this.sessionId = this.generateSessionId();
|
|
this.startBatchInterval();
|
|
this.setupPageUnloadHandler();
|
|
}
|
|
|
|
/**
|
|
* Gera um ID de sessão único
|
|
*/
|
|
private generateSessionId(): string {
|
|
if (typeof window === "undefined") return "";
|
|
|
|
// Tentar recuperar da sessionStorage
|
|
let sessionId = sessionStorage.getItem("telemetry_session_id");
|
|
if (sessionId) return sessionId;
|
|
|
|
// Gerar novo ID
|
|
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
sessionStorage.setItem("telemetry_session_id", sessionId);
|
|
return sessionId;
|
|
}
|
|
|
|
/**
|
|
* Inicia tracking de visualização de anúncio
|
|
*/
|
|
trackAdView(adId: number): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const startTime = Date.now();
|
|
this.viewStartTimes.set(adId, startTime);
|
|
|
|
this.queueEvent({
|
|
event_type: "ad_view",
|
|
ad_id: adId,
|
|
metadata: {
|
|
timestamp: startTime,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Finaliza tracking de visualização com duração
|
|
*/
|
|
trackAdExit(adId: number): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const startTime = this.viewStartTimes.get(adId);
|
|
if (!startTime) return;
|
|
|
|
const duration = (Date.now() - startTime) / 1000; // em segundos
|
|
this.viewStartTimes.delete(adId);
|
|
|
|
this.queueEvent({
|
|
event_type: "ad_exit",
|
|
ad_id: adId,
|
|
metadata: {
|
|
duration,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra clique em anúncio
|
|
*/
|
|
trackAdClick(adId: number, position?: number): void {
|
|
this.queueEvent({
|
|
event_type: "ad_click",
|
|
ad_id: adId,
|
|
metadata: {
|
|
position,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra busca
|
|
*/
|
|
trackSearch(query: string, filters?: Record<string, any>): void {
|
|
this.queueEvent({
|
|
event_type: "search",
|
|
metadata: {
|
|
query,
|
|
filters,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra aplicação de filtros
|
|
*/
|
|
trackFilterApplied(filters: Record<string, any>): void {
|
|
this.queueEvent({
|
|
event_type: "filter",
|
|
metadata: {
|
|
filters,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra favoritar/desfavoritar
|
|
*/
|
|
trackFavorite(adId: number, action: "favorite" | "unfavorite"): void {
|
|
this.queueEvent({
|
|
event_type: action,
|
|
ad_id: adId,
|
|
metadata: {
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra compra
|
|
*/
|
|
trackPurchase(adId: number, price: number): void {
|
|
this.queueEvent({
|
|
event_type: "purchase",
|
|
ad_id: adId,
|
|
metadata: {
|
|
price,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
|
|
// Enviar imediatamente para eventos críticos
|
|
this.flushQueue();
|
|
}
|
|
|
|
/**
|
|
* Registra inquiry (mensagem ao vendedor)
|
|
*/
|
|
trackInquiry(adId: number): void {
|
|
this.queueEvent({
|
|
event_type: "inquiry",
|
|
ad_id: adId,
|
|
metadata: {
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra visualização de página
|
|
*/
|
|
trackPageView(page: string): void {
|
|
this.queueEvent({
|
|
event_type: "page_view",
|
|
metadata: {
|
|
page,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registra evento customizado (para eventos que não têm método específico)
|
|
*/
|
|
trackCustomEvent(
|
|
eventType: TelemetryEventType | string,
|
|
adId?: number,
|
|
metadata?: Record<string, any>,
|
|
): void {
|
|
this.queueEvent({
|
|
event_type: eventType as TelemetryEventType,
|
|
ad_id: adId,
|
|
metadata: {
|
|
...metadata,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adiciona evento à fila
|
|
*/
|
|
private queueEvent(event: TelemetryEvent): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
this.eventQueue.push({
|
|
...event,
|
|
session_id: this.sessionId,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Enviar imediatamente se a fila estiver cheia
|
|
if (this.eventQueue.length >= this.MAX_BATCH_SIZE) {
|
|
this.flushQueue();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Envia eventos em batch para o servidor
|
|
*/
|
|
async flushQueue(): Promise<void> {
|
|
if (this.eventQueue.length === 0) return;
|
|
|
|
const eventsToSend = [...this.eventQueue];
|
|
this.eventQueue = [];
|
|
|
|
try {
|
|
const response = await fetch("/api/telemetry", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
events: eventsToSend.map(({ timestamp, ...event }) => ({
|
|
...event,
|
|
session_id: this.sessionId,
|
|
})),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Re-adicionar eventos à fila se falhar (mas não logar erro para não poluir console)
|
|
this.eventQueue.unshift(...eventsToSend);
|
|
// Silenciar erro para não gerar mensagens no console
|
|
}
|
|
} catch (_error) {
|
|
// Re-adicionar eventos à fila se falhar (mas não logar erro)
|
|
this.eventQueue.unshift(...eventsToSend);
|
|
// Silenciar erro para não gerar mensagens no console
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inicia intervalo de envio automático
|
|
*/
|
|
private startBatchInterval(): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
this.batchInterval = setInterval(() => {
|
|
this.flushQueue();
|
|
}, this.BATCH_INTERVAL_MS);
|
|
}
|
|
|
|
/**
|
|
* Para o intervalo de envio automático
|
|
*/
|
|
stop(): void {
|
|
if (this.batchInterval) {
|
|
clearInterval(this.batchInterval);
|
|
this.batchInterval = null;
|
|
}
|
|
// Enviar eventos pendentes antes de parar
|
|
this.flushQueue();
|
|
}
|
|
|
|
/**
|
|
* Configura handler para enviar eventos antes de sair da página
|
|
*/
|
|
private setupPageUnloadHandler(): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
// Navigator.sendBeacon é mais confiável para page unload
|
|
window.addEventListener("beforeunload", () => {
|
|
if (this.eventQueue.length === 0) return;
|
|
|
|
try {
|
|
const eventsToSend = this.eventQueue.map(
|
|
({ timestamp, ...event }) => event,
|
|
);
|
|
|
|
// Usar sendBeacon para garantir envio
|
|
const eventsWithSession = eventsToSend.map((event) => ({
|
|
...event,
|
|
session_id: this.sessionId,
|
|
}));
|
|
|
|
// Usar Blob para evitar problemas de message channel
|
|
const blob = new Blob([JSON.stringify({ events: eventsWithSession })], {
|
|
type: "application/json",
|
|
});
|
|
|
|
if (navigator.sendBeacon) {
|
|
navigator.sendBeacon("/api/telemetry", blob);
|
|
}
|
|
} catch (_error) {
|
|
// Silenciar erro para não gerar mensagens no console
|
|
}
|
|
});
|
|
|
|
// Fallback para visibilitychange
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden") {
|
|
this.flushQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Atualiza user_id (útil após login)
|
|
*/
|
|
setUserId(userId: number | undefined): void {
|
|
this.userId = userId;
|
|
}
|
|
}
|
|
|
|
// Instância singleton
|
|
let telemetryInstance: MarketTelemetry | null = null;
|
|
|
|
/**
|
|
* Obtém ou cria instância singleton de telemetria
|
|
*/
|
|
export function getTelemetry(userId?: number): MarketTelemetry {
|
|
if (!telemetryInstance) {
|
|
telemetryInstance = new MarketTelemetry(userId);
|
|
} else if (userId !== undefined) {
|
|
telemetryInstance.setUserId(userId);
|
|
}
|
|
return telemetryInstance;
|
|
}
|