diff --git a/bun.lock b/bun.lock index fbfd733..c79189f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/selection": "0.41.0", + "@microsoft/clarity": "^1.0.2", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", @@ -541,6 +542,8 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], + "@microsoft/clarity": ["@microsoft/clarity@1.0.2", "", {}, "sha512-9EZYROFpJxEGmQpHvUFqvD3ZJ7QQSqnibYSWmS+1xusoZfG1QQ1/Al9yVBBc11DWMbJrs1pe1hLT273it/skJg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.6.1", "", { "dependencies": { "state-local": "1.0.7" } }, "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg=="], diff --git a/package.json b/package.json index 40f457d..244d35c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/selection": "0.41.0", + "@microsoft/clarity": "^1.0.2", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 46f806c..39f21b1 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -35,6 +35,7 @@ import { AnnouncementHost } from "@/components/frontend/announcements/Announceme import type { AnnouncementEntry } from "@/lib/announcements/evaluate"; import { TourHost } from "@/components/frontend/tours/TourHost"; import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate"; +import { ClarityAnalytics } from "@/components/frontend/analytics/ClarityAnalytics"; // Skip static prerendering of this layout (and all pages under (frontend)/). // The layout calls `getPayload()` at render time to resolve the current user, @@ -242,6 +243,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { {isLogistics && } + ); diff --git a/src/app/login/layout.tsx b/src/app/login/layout.tsx index 20a6439..3ff702f 100644 --- a/src/app/login/layout.tsx +++ b/src/app/login/layout.tsx @@ -1,9 +1,13 @@ import "@/app/(frontend)/styles.css"; +import { ClarityAnalytics } from "@/components/frontend/analytics/ClarityAnalytics"; export default function LoginLayout(props: { children: React.ReactNode }) { return ( - {props.children} + + {props.children} + + ); } diff --git a/src/components/frontend/analytics/ClarityAnalytics.tsx b/src/components/frontend/analytics/ClarityAnalytics.tsx new file mode 100644 index 0000000..91b81db --- /dev/null +++ b/src/components/frontend/analytics/ClarityAnalytics.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; +import Clarity from "@microsoft/clarity"; + +// NEXT_PUBLIC_ vars are statically inlined at build time, so an unset +// NEXT_PUBLIC_CLARITY_ID is baked in as undefined for that build. +const CLARITY_ID = process.env.NEXT_PUBLIC_CLARITY_ID; + +/** + * Microsoft Clarity usage analytics (https://clarity.microsoft.com). + * + * Renders nothing. Initializes only when the build is a production build + * AND NEXT_PUBLIC_CLARITY_ID was set at build time, so local dev and + * ID-less builds stay untracked. Clarity records SPA route changes on its + * own, so no per-navigation wiring is needed. + * + * Mount once per root layout that should be tracked: the (frontend) layout + * and the standalone /login layout. The Payload admin panel is untracked. + */ +export function ClarityAnalytics() { + useEffect(() => { + if (process.env.NODE_ENV !== "production" || !CLARITY_ID) return; + try { + Clarity.init(CLARITY_ID); + } catch (e) { + // Fire-and-forget: analytics must never break the app if the script + // is blocked (ad blockers) or Clarity is unreachable. + console.warn("[clarity] init failed:", e); + } + }, []); + + return null; +}