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 ;
+}
diff --git a/apps/web/src/components/analytics/TrackedLink.tsx b/apps/web/src/components/analytics/TrackedLink.tsx
new file mode 100644
index 0000000..3596340
--- /dev/null
+++ b/apps/web/src/components/analytics/TrackedLink.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import React from "react";
+import Link, { LinkProps } from "next/link";
+import { useAnalytics } from "./useAnalytics";
+import { AnalyticsEvents } from "./analytics-events";
+
+interface TrackedLinkProps extends LinkProps {
+ eventName?: string;
+ eventProperties?: Record;
+ className?: string;
+ children: React.ReactNode;
+ onClick?: (e: React.MouseEvent) => void;
+ // allow passing other props
+ [key: string]: any;
+}
+
+/**
+ * A wrapper around next/link that tracks the click event.
+ */
+export function TrackedLink({
+ href,
+ eventName = AnalyticsEvents.LINK_CLICK,
+ eventProperties = {},
+ className,
+ children,
+ onClick,
+ ...props
+}: TrackedLinkProps) {
+ const { trackEvent } = useAnalytics();
+
+ const handleClick = (e: React.MouseEvent) => {
+ trackEvent(eventName, {
+ href: href.toString(),
+ ...eventProperties,
+ });
+ if (onClick) onClick(e);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/analytics/analytics-events.ts b/apps/web/src/components/analytics/analytics-events.ts
new file mode 100644
index 0000000..d4d2b7f
--- /dev/null
+++ b/apps/web/src/components/analytics/analytics-events.ts
@@ -0,0 +1,44 @@
+/**
+ * Analytics Events Utility
+ *
+ * Centralized definitions for common analytics events and their properties.
+ */
+
+export const AnalyticsEvents = {
+ // Page & Navigation Events
+ PAGE_VIEW: "pageview",
+ PAGE_SCROLL: "page_scroll",
+ PAGE_EXIT: "page_exit",
+ SCROLL_DEPTH: "scroll_depth",
+
+ // User Interaction Events
+ BUTTON_CLICK: "button_click",
+ LINK_CLICK: "link_click",
+ FORM_SUBMIT: "form_submit",
+ FORM_START: "form_start",
+ FORM_ERROR: "form_error",
+ FORM_FIELD_FOCUS: "form_field_focus",
+
+ // UI Interaction Events
+ MODAL_OPEN: "modal_open",
+ MODAL_CLOSE: "modal_close",
+ TOGGLE_SWITCH: "toggle_switch",
+ ACCORDION_TOGGLE: "accordion_toggle",
+ TAB_SWITCH: "tab_switch",
+
+ // Error & Performance Events
+ ERROR: "error",
+ PERFORMANCE: "performance",
+ API_ERROR: "api_error",
+ API_SUCCESS: "api_success",
+
+ // Custom Business Events
+ CONTACT_FORM_SUBMIT: "contact_form_submit",
+ NEWSLETTER_SUBSCRIBE: "newsletter_subscribe",
+} as const;
+
+/**
+ * Type-safe event properties for common events
+ */
+export type AnalyticsEventName =
+ (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
diff --git a/apps/web/src/components/analytics/useAnalytics.ts b/apps/web/src/components/analytics/useAnalytics.ts
new file mode 100644
index 0000000..73935e4
--- /dev/null
+++ b/apps/web/src/components/analytics/useAnalytics.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useCallback } from "react";
+import { getDefaultAnalytics } from "../../utils/analytics";
+import type { AnalyticsEventName } from "./analytics-events";
+
+/**
+ * Custom hook for tracking analytics events.
+ * Wraps the analytics service for easy use in components.
+ */
+export function useAnalytics() {
+ const trackEvent = useCallback(
+ (
+ eventName: string | AnalyticsEventName,
+ properties?: Record,
+ ) => {
+ const analytics = getDefaultAnalytics();
+ analytics.trackEvent(eventName, properties);
+
+ if (process.env.NODE_ENV === "development") {
+ console.debug("[Analytics] Tracked event:", eventName, properties);
+ }
+ },
+ [],
+ );
+
+ const trackPageview = useCallback((url?: string) => {
+ const analytics = getDefaultAnalytics();
+ analytics.page(url || window.location.pathname);
+
+ if (process.env.NODE_ENV === "development") {
+ console.debug("[Analytics] Tracked pageview:", url ?? "current location");
+ }
+ }, []);
+
+ return {
+ trackEvent,
+ trackPageview,
+ };
+}
diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts
index 1016e26..a3e21dd 100644
--- a/apps/web/src/lib/env.ts
+++ b/apps/web/src/lib/env.ts
@@ -12,6 +12,14 @@ const envExtension = {
MAIL_PASS: z.string().optional(),
MAIL_FROM: z.string().optional().default("marc@mintel.me"),
MAIL_RECIPIENTS: z.string().optional().default("marc@mintel.me"),
+
+ // Analytics (Server-side)
+ UMAMI_WEBSITE_ID: z.string().optional(),
+ UMAMI_API_ENDPOINT: z
+ .string()
+ .url()
+ .optional()
+ .default("https://analytics.infra.mintel.me"),
};
/**
diff --git a/apps/web/src/utils/analytics/index.ts b/apps/web/src/utils/analytics/index.ts
index e47e143..334f095 100644
--- a/apps/web/src/utils/analytics/index.ts
+++ b/apps/web/src/utils/analytics/index.ts
@@ -96,7 +96,6 @@ export function getDefaultAnalytics(): AnalyticsService {
if (provider === "umami") {
defaultAnalytics = createUmamiAnalytics({
- websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || "",
hostUrl: env.UMAMI_API_ENDPOINT,
});
} else if (provider === "plausible") {
diff --git a/apps/web/src/utils/analytics/umami-adapter.ts b/apps/web/src/utils/analytics/umami-adapter.ts
index 0e24473..f353251 100644
--- a/apps/web/src/utils/analytics/umami-adapter.ts
+++ b/apps/web/src/utils/analytics/umami-adapter.ts
@@ -3,52 +3,76 @@
* Decoupled implementation
*/
-import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
+import type {
+ AnalyticsAdapter,
+ AnalyticsEvent,
+ AnalyticsConfig,
+} from "./interfaces";
export interface UmamiConfig extends AnalyticsConfig {
- websiteId: string;
- hostUrl?: string;
+ hostUrl?: string; // Optional, defaults to env var on server
}
export class UmamiAdapter implements AnalyticsAdapter {
- private websiteId: string;
private hostUrl: string;
constructor(config: UmamiConfig) {
- this.websiteId = config.websiteId;
- this.hostUrl = config.hostUrl || 'https://cloud.umami.is';
+ this.hostUrl = config.hostUrl || "https://analytics.infra.mintel.me";
+ }
+
+ private async sendPayload(type: "event", data: Record) {
+ try {
+ if (typeof window === "undefined") return;
+
+ const payload = {
+ hostname: window.location.hostname,
+ screen: `${window.screen.width}x${window.screen.height}`,
+ language: navigator.language,
+ referrer: document.referrer,
+ ...data,
+ };
+
+ await fetch("/stats/api/send", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ type, payload }),
+ keepalive: true,
+ });
+ } catch (e) {
+ console.error("Failed to send analytics", e);
+ }
}
async track(event: AnalyticsEvent): Promise {
- if (typeof window === 'undefined') return;
+ if (typeof window === "undefined") return;
- const w = window as any;
- if (w.umami) {
- w.umami.track(event.name, event.props);
- }
- }
-
- async page(path: string, props?: Record): Promise {
- // Umami tracks pageviews automatically by default,
- // but we can manually trigger it if needed.
- if (typeof window === 'undefined') return;
-
- const w = window as any;
- if (w.umami) {
- w.umami.track(props?.name || 'pageview', { url: path, ...props });
- }
- }
-
- async identify(userId: string, traits?: Record): Promise {
- // Umami doesn't have a direct 'identify' like Segment,
- // but we can track it as an event or session property if supported by the instance.
- await this.track({
- name: 'identify',
- props: { userId, ...traits }
+ await this.sendPayload("event", {
+ name: event.name,
+ data: event.props,
+ url: window.location.pathname + window.location.search,
});
}
+ async page(path: string, props?: Record): Promise {
+ if (typeof window === "undefined") return;
+
+ await this.sendPayload("event", {
+ url: path,
+ ...props,
+ });
+ }
+
+ async identify(
+ _userId: string,
+ _traits?: Record,
+ ): Promise {
+ // Not implemented in this version
+ }
+
+ // No script tag needed for proxy mode
getScriptTag(): string {
- return ``;
+ return "";
}
}