feat: implement market page with QueryProvider and dynamic listings
- wrap root layout with QueryProvider for React Query - replace market UnderConstruction placeholder with MarketClient and real data fetching - implement search, pagination, and sort via search params using getActiveListings - add necessary client components and queries - update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
1 parent
55eebde0ba
commit
ab37b477df
106 files changed
+15261
-301
No files matched your search
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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(({ timestamp, ...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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user