Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no persistent per-visitor identifiers: events are grouped into sessions via a daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw). - New "Analytics" section in the Payload admin, gated by a canViewAnalytics permission on Users, following the existing custom-admin-view pattern used by the calendar feature - Sessions, page views, unique visitors, top/entry/exit pages, events, device/browser breakdown, date range filter with period comparison - Raw events and daily aggregate rollups as their own collections, with configurable retention and a cron endpoint that rolls up before purging - Cookie consent banner (necessary/preferences/statistics) with categories and copy editable from a new CMS global; a track() helper that is a complete no-op without statistics consent, so nothing is ever sent before a visitor opts in - track() calls wired into the booking flow, contact form, and navigation
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import type { AnalyticsEvent } from "@/payload-types";
|
||||
|
||||
export type AggregatedStats = {
|
||||
sessions: number;
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgPagesPerSession: number;
|
||||
eventCounts: Record<string, number>;
|
||||
deviceCounts: Record<string, number>;
|
||||
browserCounts: Record<string, number>;
|
||||
topPaths: Record<string, number>;
|
||||
entryPaths: Record<string, number>;
|
||||
exitPaths: Record<string, number>;
|
||||
};
|
||||
|
||||
function bump(map: Record<string, number>, key: string | null | undefined) {
|
||||
if (!key) return;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
|
||||
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
|
||||
return Object.entries(map)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
|
||||
* one day for the cleanup rollup, or a whole dashboard-selected range for
|
||||
* live queries). Kept side-effect free and DB-agnostic so both callers
|
||||
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
|
||||
* one implementation.
|
||||
*/
|
||||
export function aggregateEvents(events: AnalyticsEvent[]): AggregatedStats {
|
||||
const eventCounts: Record<string, number> = {};
|
||||
const deviceCounts: Record<string, number> = {};
|
||||
const browserCounts: Record<string, number> = {};
|
||||
const topPaths: Record<string, number> = {};
|
||||
const entryPaths: Record<string, number> = {};
|
||||
const exitPaths: Record<string, number> = {};
|
||||
|
||||
const visitorHashes = new Set<string>();
|
||||
const sessionIds = new Set<string>();
|
||||
let pageViews = 0;
|
||||
|
||||
// Per-session first/last page_view, in chronological order, for entry/exit pages.
|
||||
const sessionPageViews = new Map<string, { first: { at: string; path: string }; last: { at: string; path: string } }>();
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
|
||||
for (const ev of sorted) {
|
||||
visitorHashes.add(ev.visitorHash);
|
||||
sessionIds.add(ev.sessionId);
|
||||
bump(eventCounts, ev.eventName);
|
||||
bump(deviceCounts, ev.device);
|
||||
bump(browserCounts, ev.browser);
|
||||
|
||||
if (ev.eventName === "page_view") {
|
||||
pageViews += 1;
|
||||
bump(topPaths, ev.path);
|
||||
|
||||
const existing = sessionPageViews.get(ev.sessionId);
|
||||
if (!existing) {
|
||||
sessionPageViews.set(ev.sessionId, {
|
||||
first: { at: ev.createdAt, path: ev.path },
|
||||
last: { at: ev.createdAt, path: ev.path },
|
||||
});
|
||||
} else {
|
||||
existing.last = { at: ev.createdAt, path: ev.path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { first, last } of sessionPageViews.values()) {
|
||||
bump(entryPaths, first.path);
|
||||
bump(exitPaths, last.path);
|
||||
}
|
||||
|
||||
const sessions = sessionIds.size;
|
||||
|
||||
return {
|
||||
sessions,
|
||||
pageViews,
|
||||
uniqueVisitors: visitorHashes.size,
|
||||
avgPagesPerSession: sessions > 0 ? Math.round((pageViews / sessions) * 10) / 10 : 0,
|
||||
eventCounts,
|
||||
deviceCounts,
|
||||
browserCounts,
|
||||
topPaths,
|
||||
entryPaths,
|
||||
exitPaths,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user