From 2038b8fe47257560f45a27c20c19bce56fa61908 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 16 Feb 2026 23:03:42 +0100 Subject: [PATCH] feat(analytics): implement advanced tracking with script-less smart proxy - Added Umami Smart Proxy route handler - Refactored Umami adapter to use proxy-based fetch - Implemented TrackedButton, TrackedLink, and ScrollDepthTracker - Integrated event tracking into ContactForm - Enhanced Analytics component with manual pageview and performance tracking --- apps/web/app/stats/api/send/route.ts | 77 +++++++++++++ apps/web/next.config.mjs | 5 +- apps/web/src/components/Analytics.tsx | 105 +++++++++++++----- apps/web/src/components/Button.tsx | 12 +- apps/web/src/components/ContactForm.tsx | 9 ++ .../analytics/ScrollDepthTracker.tsx | 70 ++++++++++++ .../components/analytics/TrackedButton.tsx | 48 ++++++++ .../src/components/analytics/TrackedLink.tsx | 45 ++++++++ .../components/analytics/analytics-events.ts | 44 ++++++++ .../src/components/analytics/useAnalytics.ts | 40 +++++++ apps/web/src/lib/env.ts | 8 ++ apps/web/src/utils/analytics/index.ts | 1 - apps/web/src/utils/analytics/umami-adapter.ts | 86 ++++++++------ 13 files changed, 485 insertions(+), 65 deletions(-) create mode 100644 apps/web/app/stats/api/send/route.ts create mode 100644 apps/web/src/components/analytics/ScrollDepthTracker.tsx create mode 100644 apps/web/src/components/analytics/TrackedButton.tsx create mode 100644 apps/web/src/components/analytics/TrackedLink.tsx create mode 100644 apps/web/src/components/analytics/analytics-events.ts create mode 100644 apps/web/src/components/analytics/useAnalytics.ts diff --git a/apps/web/app/stats/api/send/route.ts b/apps/web/app/stats/api/send/route.ts new file mode 100644 index 0000000..7e5efca --- /dev/null +++ b/apps/web/app/stats/api/send/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +/** + * Smart Proxy for Umami Analytics. + * + * This Route Handler receives tracking events from the browser, + * injects the secret UMAMI_WEBSITE_ID, and forwards them to the + * internal Umami API endpoint. + * + * This ensures: + * 1. The Website ID is NOT leaked to the client bundle. + * 2. The Umami API endpoint is hidden behind our domain. + * 3. We have full control over the tracking data. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type, payload } = body; + + // Inject the secret websiteId from server config + const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID; + + if (!websiteId) { + console.warn( + "Umami tracking received but no Website ID configured on server", + ); + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + // Prepare the enhanced payload with the secret ID + const enhancedPayload = { + ...payload, + website: websiteId, + }; + + const umamiEndpoint = env.UMAMI_API_ENDPOINT; + + // Log the event (debug only) + if (process.env.NODE_ENV === "development") { + console.debug("Forwarding analytics event", { + type, + url: payload.url, + website: websiteId.slice(0, 8) + "...", + }); + } + + const response = await fetch(`${umamiEndpoint}/api/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy", + "X-Forwarded-For": request.headers.get("x-forwarded-for") || "", + }, + body: JSON.stringify({ type, payload: enhancedPayload }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Umami API responded with error", { + status: response.status, + error: errorText.slice(0, 100), + }); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: "ok" }); + } catch (error) { + console.error("Failed to proxy analytics request", { + error: (error as Error).message, + }); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index a03c8d8..72f47ea 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -19,10 +19,7 @@ const nextConfig = { : "https://errors.infra.mintel.me"; return [ - { - source: "/stats/:path*", - destination: `${umamiUrl}/:path*`, - }, + // Umami proxy rewrite removed in favor of app/stats/api/send/route.ts { source: "/errors/:path*", destination: `${glitchtipUrl}/:path*`, diff --git a/apps/web/src/components/Analytics.tsx b/apps/web/src/components/Analytics.tsx index 9b41c75..a49e814 100644 --- a/apps/web/src/components/Analytics.tsx +++ b/apps/web/src/components/Analytics.tsx @@ -1,34 +1,59 @@ -'use client'; +"use client"; -import { useEffect } from 'react'; -import { getDefaultAnalytics } from '../utils/analytics'; -import { getDefaultErrorTracking } from '../utils/error-tracking'; +import React, { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker"; +import { getDefaultAnalytics } from "../utils/analytics"; +import { getDefaultErrorTracking } from "../utils/error-tracking"; export const Analytics: React.FC = () => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Track pageviews on route change + useEffect(() => { + if (!pathname) return; + + const analytics = getDefaultAnalytics(); + const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`; + + analytics.page(url); + }, [pathname, searchParams]); + useEffect(() => { const analytics = getDefaultAnalytics(); const errorTracking = getDefaultErrorTracking(); // Track page load performance const trackPageLoad = () => { - const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; - if (perfData && typeof perfData.loadEventEnd === 'number' && typeof perfData.startTime === 'number') { + // ... existing implementation ... + const perfData = performance.getEntriesByType( + "navigation", + )[0] as PerformanceNavigationTiming; + if ( + perfData && + typeof perfData.loadEventEnd === "number" && + typeof perfData.startTime === "number" + ) { const loadTime = perfData.loadEventEnd - perfData.startTime; analytics.trackPageLoad( loadTime, window.location.pathname, - navigator.userAgent + navigator.userAgent, ); } }; // Track outbound links const trackOutboundLinks = () => { - document.querySelectorAll('a[href^="http"]').forEach(link => { + document.querySelectorAll('a[href^="http"]').forEach((link) => { const anchor = link as HTMLAnchorElement; if (!anchor.href.includes(window.location.hostname)) { - anchor.addEventListener('click', () => { - analytics.trackOutboundLink(anchor.href, anchor.textContent?.trim() || 'unknown'); + anchor.addEventListener("click", () => { + analytics.trackOutboundLink( + anchor.href, + anchor.textContent?.trim() || "unknown", + ); }); } }); @@ -36,7 +61,9 @@ export const Analytics: React.FC = () => { // Track search const trackSearch = () => { - const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement; + const searchInput = document.querySelector( + 'input[type="search"]', + ) as HTMLInputElement; if (searchInput) { const handleSearch = (e: Event) => { const target = e.target as HTMLInputElement; @@ -44,8 +71,8 @@ export const Analytics: React.FC = () => { analytics.trackSearch(target.value, window.location.pathname); } }; - searchInput.addEventListener('search', handleSearch); - return () => searchInput.removeEventListener('search', handleSearch); + searchInput.addEventListener("search", handleSearch); + return () => searchInput.removeEventListener("search", handleSearch); } }; @@ -58,18 +85,37 @@ export const Analytics: React.FC = () => { errorTracking.captureException(event.reason); }; - window.addEventListener('error', handleGlobalError); - window.addEventListener('unhandledrejection', handleUnhandledRejection); + window.addEventListener("error", handleGlobalError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); - trackPageLoad(); - trackOutboundLinks(); - const cleanupSearch = trackSearch(); - - return () => { - if (cleanupSearch) cleanupSearch(); - window.removeEventListener('error', handleGlobalError); - window.removeEventListener('unhandledrejection', handleUnhandledRejection); - }; + // Initial load tracking + if (document.readyState === "complete") { + trackPageLoad(); + trackOutboundLinks(); + const cleanupSearch = trackSearch(); + return () => { + if (cleanupSearch) cleanupSearch(); + window.removeEventListener("error", handleGlobalError); + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); + }; + } else { + window.addEventListener("load", () => { + trackPageLoad(); + trackOutboundLinks(); + // search tracking might need to wait for hydration/render + }); + // Fallback/standard cleanup + return () => { + window.removeEventListener("error", handleGlobalError); + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); + }; + } }, []); const analytics = getDefaultAnalytics(); @@ -81,9 +127,12 @@ export const Analytics: React.FC = () => { // We use dangerouslySetInnerHTML to inject the script tag from the adapter // This is safe here because the script URLs and IDs come from our own config/env return ( -
+ <> + +
+ ); }; diff --git a/apps/web/src/components/Button.tsx b/apps/web/src/components/Button.tsx index 32d475c..de3ca92 100644 --- a/apps/web/src/components/Button.tsx +++ b/apps/web/src/components/Button.tsx @@ -12,6 +12,8 @@ interface ButtonProps { size?: "normal" | "large"; className?: string; showArrow?: boolean; + onClick?: (e: React.MouseEvent) => void; + [key: string]: any; } /** @@ -30,6 +32,8 @@ export const Button: React.FC = ({ size = "normal", className = "", showArrow = true, + onClick, + ...props }) => { const [hovered, setHovered] = React.useState(false); const [displayText, setDisplayText] = React.useState(null); @@ -153,14 +157,20 @@ export const Button: React.FC = ({ { + if (onClick) onClick(e); e.preventDefault(); document.querySelector(href)?.scrollIntoView({ behavior: "smooth" }); }} + {...props} > {inner} ); } - return {inner}; + return ( + + {inner} + + ); }; diff --git a/apps/web/src/components/ContactForm.tsx b/apps/web/src/components/ContactForm.tsx index efa95e6..fe3462d 100644 --- a/apps/web/src/components/ContactForm.tsx +++ b/apps/web/src/components/ContactForm.tsx @@ -13,6 +13,9 @@ import { AlertCircle, } from "lucide-react"; +import { useAnalytics } from "./analytics/useAnalytics"; +import { AnalyticsEvents } from "./analytics/analytics-events"; + import { FormState } from "./ContactForm/types"; import { PRICING, @@ -69,6 +72,8 @@ export function ContactForm({ const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const { trackEvent } = useAnalytics(); + const containerRef = useRef(null); // Scroll to top on flow or step change @@ -131,6 +136,10 @@ export function ContactForm({ if (result.success) { setIsSubmitted(true); + trackEvent(AnalyticsEvents.CONTACT_FORM_SUBMIT, { + flow, + config: flow === "configurator" ? state : undefined, + }); // Celebration const duration = 3 * 1000; const animationEnd = Date.now() + duration; diff --git a/apps/web/src/components/analytics/ScrollDepthTracker.tsx b/apps/web/src/components/analytics/ScrollDepthTracker.tsx new file mode 100644 index 0000000..c9f7d88 --- /dev/null +++ b/apps/web/src/components/analytics/ScrollDepthTracker.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; +import { useAnalytics } from "./useAnalytics"; +import { AnalyticsEvents } from "./analytics-events"; + +/** + * ScrollDepthTracker + * Tracks user scroll progress across pages. + * Fires events at 25%, 50%, 75%, and 100% depth. + */ +export function ScrollDepthTracker() { + const pathname = usePathname(); + const { trackEvent } = useAnalytics(); + const trackedDepths = useRef>(new Set()); + + // Reset tracking when path changes + useEffect(() => { + trackedDepths.current.clear(); + }, [pathname]); + + useEffect(() => { + const handleScroll = () => { + const scrollY = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + // Calculate how far the user has scrolled in percentage + // documentHeight - windowHeight is the total scrollable distance + const totalScrollable = documentHeight - windowHeight; + if (totalScrollable <= 0) return; // Not scrollable + + const scrollPercentage = Math.round((scrollY / totalScrollable) * 100); + + // We only care about specific milestones + const milestones = [25, 50, 75, 100]; + + milestones.forEach((milestone) => { + if ( + scrollPercentage >= milestone && + !trackedDepths.current.has(milestone) + ) { + trackedDepths.current.add(milestone); + trackEvent(AnalyticsEvents.SCROLL_DEPTH, { + depth: milestone, + path: pathname, + }); + } + }); + }; + + // Use passive listener for better performance + window.addEventListener("scroll", handleScroll, { passive: true }); + + // Initial check (in case page is short or already scrolled) + if (document.readyState === "complete") { + handleScroll(); + } else { + window.addEventListener("load", handleScroll); + } + + return () => { + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("load", handleScroll); + }; + }, [pathname, trackEvent]); + + return null; +} diff --git a/apps/web/src/components/analytics/TrackedButton.tsx b/apps/web/src/components/analytics/TrackedButton.tsx new file mode 100644 index 0000000..3b30ec6 --- /dev/null +++ b/apps/web/src/components/analytics/TrackedButton.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import { Button } from "../Button"; +import { useAnalytics } from "./useAnalytics"; +import { AnalyticsEvents } from "./analytics-events"; + +// Since ButtonProps is not exported from Button.tsx, we define a compatible interface +interface ButtonProps { + href: string; // Correctly matching Button.tsx which has href as required + children: React.ReactNode; + variant?: "primary" | "outline" | "ghost"; + size?: "normal" | "large"; + className?: string; + showArrow?: boolean; + [key: string]: any; // Allow other props +} + +interface TrackedButtonProps extends ButtonProps { + eventName?: string; + eventProperties?: Record; + onClick?: (e: React.MouseEvent) => void; +} + +/** + * A wrapper around the project's Button component that tracks click events. + */ +export function TrackedButton({ + eventName = AnalyticsEvents.BUTTON_CLICK, + eventProperties = {}, + onClick, + ...props +}: TrackedButtonProps) { + const { trackEvent } = useAnalytics(); + + const handleClick = (e: React.MouseEvent) => { + trackEvent(eventName, { + ...eventProperties, + label: + typeof props.children === "string" + ? props.children + : eventProperties.label, + }); + if (onClick) onClick(e); + }; + + return