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
70 lines
2.3 KiB
TypeScript
70 lines
2.3 KiB
TypeScript
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 };
|
|
}
|