feat(analytics): implement advanced tracking with script-less smart proxy
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Failing after 1m18s
Build & Deploy / 🏗️ Build (push) Failing after 2m58s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Failing after 1m18s
Build & Deploy / 🏗️ Build (push) Failing after 2m58s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- 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
This commit is contained in:
77
apps/web/app/stats/api/send/route.ts
Normal file
77
apps/web/app/stats/api/send/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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*`,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<>
|
||||
<ScrollDepthTracker />
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ interface ButtonProps {
|
||||
size?: "normal" | "large";
|
||||
className?: string;
|
||||
showArrow?: boolean;
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,8 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
size = "normal",
|
||||
className = "",
|
||||
showArrow = true,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const [displayText, setDisplayText] = React.useState<string | null>(null);
|
||||
@@ -153,14 +157,20 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
if (onClick) onClick(e);
|
||||
e.preventDefault();
|
||||
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <Link href={href}>{inner}</Link>;
|
||||
return (
|
||||
<Link href={href} onClick={onClick} {...props}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(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;
|
||||
|
||||
70
apps/web/src/components/analytics/ScrollDepthTracker.tsx
Normal file
70
apps/web/src/components/analytics/ScrollDepthTracker.tsx
Normal file
@@ -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<Set<number>>(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;
|
||||
}
|
||||
48
apps/web/src/components/analytics/TrackedButton.tsx
Normal file
48
apps/web/src/components/analytics/TrackedButton.tsx
Normal file
@@ -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<string, any>;
|
||||
onClick?: (e: React.MouseEvent<any>) => 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<any>) => {
|
||||
trackEvent(eventName, {
|
||||
...eventProperties,
|
||||
label:
|
||||
typeof props.children === "string"
|
||||
? props.children
|
||||
: eventProperties.label,
|
||||
});
|
||||
if (onClick) onClick(e);
|
||||
};
|
||||
|
||||
return <Button {...props} onClick={handleClick} />;
|
||||
}
|
||||
45
apps/web/src/components/analytics/TrackedLink.tsx
Normal file
45
apps/web/src/components/analytics/TrackedLink.tsx
Normal file
@@ -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<string, any>;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement, 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<HTMLAnchorElement, MouseEvent>) => {
|
||||
trackEvent(eventName, {
|
||||
href: href.toString(),
|
||||
...eventProperties,
|
||||
});
|
||||
if (onClick) onClick(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={href} className={className} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/components/analytics/analytics-events.ts
Normal file
44
apps/web/src/components/analytics/analytics-events.ts
Normal file
@@ -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];
|
||||
40
apps/web/src/components/analytics/useAnalytics.ts
Normal file
40
apps/web/src/components/analytics/useAnalytics.ts
Normal file
@@ -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<string, any>,
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, any>) {
|
||||
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<void> {
|
||||
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<string, any>): Promise<void> {
|
||||
// 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<string, any>): Promise<void> {
|
||||
// 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<string, any>): Promise<void> {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
await this.sendPayload("event", {
|
||||
url: path,
|
||||
...props,
|
||||
});
|
||||
}
|
||||
|
||||
async identify(
|
||||
_userId: string,
|
||||
_traits?: Record<string, any>,
|
||||
): Promise<void> {
|
||||
// Not implemented in this version
|
||||
}
|
||||
|
||||
// No script tag needed for proxy mode
|
||||
getScriptTag(): string {
|
||||
return `<script async src="${this.hostUrl}/script.js" data-website-id="${this.websiteId}"></script>`;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user