feat: add Review Management module and UI layout fixes
This commit is contained in:
194
src/lib/ads/tracking.ts
Normal file
194
src/lib/ads/tracking.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Ad Tracking Engine — Impression/Click recording, deduplication, rollups
|
||||
|
||||
import type { AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
|
||||
import type { SurfaceKey } from '@/lib/types/ad-control';
|
||||
|
||||
const TRACKING_KEY = 'ad_tracking_events';
|
||||
const DAILY_STATS_KEY = 'ad_daily_stats';
|
||||
const DEDUPE_WINDOW_MS = 30_000; // 30 seconds
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
function getTrackingStore(): AdTrackingEvent[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try { return JSON.parse(localStorage.getItem(TRACKING_KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveTrackingStore(events: AdTrackingEvent[]) {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Cap at 5000 to avoid localStorage limits
|
||||
localStorage.setItem(TRACKING_KEY, JSON.stringify(events.slice(-5000)));
|
||||
}
|
||||
|
||||
function getDailyStatsStore(): PlacementDailyStats[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try { return JSON.parse(localStorage.getItem(DAILY_STATS_KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveDailyStatsStore(stats: PlacementDailyStats[]) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(DAILY_STATS_KEY, JSON.stringify(stats));
|
||||
}
|
||||
|
||||
// --- Deduplication ---
|
||||
|
||||
function isDuplicate(events: AdTrackingEvent[], newEvent: Omit<AdTrackingEvent, 'id'>): boolean {
|
||||
const cutoff = new Date(new Date(newEvent.timestamp).getTime() - DEDUPE_WINDOW_MS).toISOString();
|
||||
return events.some(e =>
|
||||
e.type === newEvent.type &&
|
||||
e.placementId === newEvent.placementId &&
|
||||
e.anonId === newEvent.anonId &&
|
||||
e.timestamp > cutoff
|
||||
);
|
||||
}
|
||||
|
||||
// --- Record Events ---
|
||||
|
||||
export function recordImpression(data: {
|
||||
placementId: string;
|
||||
campaignId: string;
|
||||
surfaceKey: SurfaceKey;
|
||||
eventId: string;
|
||||
userId?: string | null;
|
||||
anonId: string;
|
||||
sessionId: string;
|
||||
device?: string;
|
||||
cityId?: string;
|
||||
}): { success: boolean; deduplicated?: boolean } {
|
||||
const store = getTrackingStore();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const event: Omit<AdTrackingEvent, 'id'> = {
|
||||
type: 'IMPRESSION',
|
||||
placementId: data.placementId,
|
||||
campaignId: data.campaignId,
|
||||
surfaceKey: data.surfaceKey,
|
||||
eventId: data.eventId,
|
||||
userId: data.userId || null,
|
||||
anonId: data.anonId,
|
||||
sessionId: data.sessionId,
|
||||
timestamp: now,
|
||||
device: data.device || 'unknown',
|
||||
cityId: data.cityId || 'unknown',
|
||||
};
|
||||
|
||||
if (isDuplicate(store, event)) {
|
||||
return { success: true, deduplicated: true };
|
||||
}
|
||||
|
||||
const fullEvent: AdTrackingEvent = {
|
||||
...event,
|
||||
id: `te-imp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
};
|
||||
|
||||
store.push(fullEvent);
|
||||
saveTrackingStore(store);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function recordClick(data: {
|
||||
placementId: string;
|
||||
campaignId: string;
|
||||
surfaceKey: SurfaceKey;
|
||||
eventId: string;
|
||||
userId?: string | null;
|
||||
anonId: string;
|
||||
sessionId: string;
|
||||
device?: string;
|
||||
cityId?: string;
|
||||
}): { success: boolean } {
|
||||
const store = getTrackingStore();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const fullEvent: AdTrackingEvent = {
|
||||
id: `te-clk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'CLICK',
|
||||
placementId: data.placementId,
|
||||
campaignId: data.campaignId,
|
||||
surfaceKey: data.surfaceKey,
|
||||
eventId: data.eventId,
|
||||
userId: data.userId || null,
|
||||
anonId: data.anonId,
|
||||
sessionId: data.sessionId,
|
||||
timestamp: now,
|
||||
device: data.device || 'unknown',
|
||||
cityId: data.cityId || 'unknown',
|
||||
};
|
||||
|
||||
store.push(fullEvent);
|
||||
saveTrackingStore(store);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
export function getTrackingEvents(campaignId?: string, type?: 'IMPRESSION' | 'CLICK'): AdTrackingEvent[] {
|
||||
let events = getTrackingStore();
|
||||
if (campaignId) events = events.filter(e => e.campaignId === campaignId);
|
||||
if (type) events = events.filter(e => e.type === type);
|
||||
return events;
|
||||
}
|
||||
|
||||
export function getUserImpressionCount(anonId: string, campaignId: string, date: string): number {
|
||||
const events = getTrackingStore();
|
||||
return events.filter(e =>
|
||||
e.type === 'IMPRESSION' &&
|
||||
e.anonId === anonId &&
|
||||
e.campaignId === campaignId &&
|
||||
e.timestamp.startsWith(date)
|
||||
).length;
|
||||
}
|
||||
|
||||
export function getTodaySpend(campaignId: string, cpmRate: number): number {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const impressions = getTrackingStore().filter(e =>
|
||||
e.type === 'IMPRESSION' &&
|
||||
e.campaignId === campaignId &&
|
||||
e.timestamp.startsWith(today)
|
||||
).length;
|
||||
return Number(((impressions / 1000) * cpmRate).toFixed(2));
|
||||
}
|
||||
|
||||
// --- Daily Stats Rollup ---
|
||||
|
||||
export function computeDailyStats(campaignId: string, billingModel: string, bid: number): PlacementDailyStats[] {
|
||||
const events = getTrackingStore().filter(e => e.campaignId === campaignId);
|
||||
|
||||
// Group by date + surfaceKey
|
||||
const buckets: Record<string, { impressions: number; clicks: number; surfaceKey: SurfaceKey; placementId: string }> = {};
|
||||
|
||||
for (const e of events) {
|
||||
const date = e.timestamp.slice(0, 10);
|
||||
const key = `${date}|${e.surfaceKey}`;
|
||||
if (!buckets[key]) {
|
||||
buckets[key] = { impressions: 0, clicks: 0, surfaceKey: e.surfaceKey, placementId: e.placementId };
|
||||
}
|
||||
if (e.type === 'IMPRESSION') buckets[key].impressions++;
|
||||
else buckets[key].clicks++;
|
||||
}
|
||||
|
||||
return Object.entries(buckets).map(([key, data]) => {
|
||||
const [date] = key.split('|');
|
||||
let spend = 0;
|
||||
if (billingModel === 'CPM') spend = (data.impressions / 1000) * bid;
|
||||
else if (billingModel === 'CPC') spend = data.clicks * bid;
|
||||
// FIXED: spread evenly (simplified)
|
||||
else spend = bid / 30; // assume 30-day campaign
|
||||
|
||||
const ctr = data.impressions > 0 ? data.clicks / data.impressions : 0;
|
||||
|
||||
return {
|
||||
id: `ds-${key}`,
|
||||
campaignId,
|
||||
placementId: data.placementId,
|
||||
surfaceKey: data.surfaceKey,
|
||||
date,
|
||||
impressions: data.impressions,
|
||||
clicks: data.clicks,
|
||||
ctr: Number(ctr.toFixed(4)),
|
||||
spend: Number(spend.toFixed(2)),
|
||||
};
|
||||
}).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
Reference in New Issue
Block a user