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,69 @@
|
||||
import type { Payload } from "payload";
|
||||
import type { AnalyticsEvent } from "@/payload-types";
|
||||
import { aggregateEvents, type AggregatedStats } from "@/lib/analytics/aggregate";
|
||||
|
||||
export type DashboardRange = { from: Date; to: Date };
|
||||
|
||||
export type DailyPoint = { date: string; pageViews: number; sessions: number };
|
||||
|
||||
export type DashboardStats = AggregatedStats & {
|
||||
series: DailyPoint[];
|
||||
};
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
async function fetchEvents(payload: Payload, range: DashboardRange): Promise<AnalyticsEvent[]> {
|
||||
const { docs } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
where: {
|
||||
and: [
|
||||
{ createdAt: { greater_than_equal: range.from.toISOString() } },
|
||||
{ createdAt: { less_than_equal: range.to.toISOString() } },
|
||||
],
|
||||
},
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
sort: "createdAt",
|
||||
});
|
||||
return docs;
|
||||
}
|
||||
|
||||
function buildSeries(events: AnalyticsEvent[]): DailyPoint[] {
|
||||
const byDay = new Map<string, { pageViews: number; sessions: Set<string> }>();
|
||||
for (const ev of events) {
|
||||
const key = dateKey(ev.createdAt);
|
||||
const bucket = byDay.get(key) ?? { pageViews: 0, sessions: new Set<string>() };
|
||||
if (ev.eventName === "page_view") bucket.pageViews += 1;
|
||||
bucket.sessions.add(ev.sessionId);
|
||||
byDay.set(key, bucket);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, bucket]) => ({ date, pageViews: bucket.pageViews, sessions: bucket.sessions.size }));
|
||||
}
|
||||
|
||||
export async function getDashboardStats(payload: Payload, range: DashboardRange): Promise<DashboardStats> {
|
||||
const events = await fetchEvents(payload, range);
|
||||
return { ...aggregateEvents(events), series: buildSeries(events) };
|
||||
}
|
||||
|
||||
export type PercentDelta = number | null; // null = no comparison basis (previous value was 0)
|
||||
|
||||
export function percentDelta(current: number, previous: number): PercentDelta {
|
||||
if (previous === 0) return current === 0 ? 0 : null;
|
||||
return Math.round(((current - previous) / previous) * 1000) / 10;
|
||||
}
|
||||
|
||||
export async function getDashboardComparison(
|
||||
payload: Payload,
|
||||
range: DashboardRange,
|
||||
compareRange: DashboardRange,
|
||||
): Promise<{ current: DashboardStats; previous: DashboardStats }> {
|
||||
const [current, previous] = await Promise.all([
|
||||
getDashboardStats(payload, range),
|
||||
getDashboardStats(payload, compareRange),
|
||||
]);
|
||||
return { current, previous };
|
||||
}
|
||||
Reference in New Issue
Block a user