From 0e089f9471937a96867eca6019cbdfbc1e3544c6 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 16 Feb 2026 18:30:29 +0100 Subject: [PATCH] feat(analytics): implement total transparency suite and SEO metadata standardization - Added global ScrollDepthTracker (25%, 50%, 75%, 100%) - Implemented ProductEngagementTracker for deep product analytics - Added field-level tracking to ContactForm and RequestQuoteForm - Standardized SEO metadata (canonical, alternates, x-default) across all routes - Created reusable TrackedLink and TrackedButton components for server components - Fixed 'useAnalytics' hook error in Footer.tsx by adding 'use client' --- .env | 1 + app/[locale]/[slug]/page.tsx | 17 ++- app/[locale]/blog/[slug]/page.tsx | 8 +- app/[locale]/blog/page.tsx | 11 +- app/[locale]/contact/page.tsx | 5 +- app/[locale]/layout.tsx | 2 + app/[locale]/page.tsx | 8 +- app/[locale]/products/[...slug]/page.tsx | 33 ++++-- app/[locale]/products/page.tsx | 22 ++-- app/[locale]/team/page.tsx | 27 +++-- components/ContactForm.tsx | 31 ++++- components/DatasheetDownload.tsx | 47 +++++--- components/Footer.tsx | 112 +++++++++++++++++- components/Header.tsx | 62 +++++++++- components/RelatedProducts.tsx | 94 +++++++++++---- components/RequestQuoteForm.tsx | 33 ++++++ .../analytics/ProductEngagementTracker.tsx | 50 ++++++++ components/analytics/ScrollDepthTracker.tsx | 62 ++++++++++ components/analytics/TrackedButton.tsx | 34 ++++++ components/analytics/TrackedLink.tsx | 44 +++++++ components/analytics/analytics-events.ts | 14 ++- components/home/Hero.tsx | 15 +++ 22 files changed, 634 insertions(+), 98 deletions(-) create mode 100644 components/analytics/ProductEngagementTracker.tsx create mode 100644 components/analytics/ScrollDepthTracker.tsx create mode 100644 components/analytics/TrackedButton.tsx create mode 100644 components/analytics/TrackedLink.tsx diff --git a/.env b/.env index 4d366e72..6ca8abe0 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ # Application NODE_ENV=production NEXT_PUBLIC_BASE_URL=https://klz-cables.com +UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81 UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1 LOG_LEVEL=info diff --git a/app/[locale]/[slug]/page.tsx b/app/[locale]/[slug]/page.tsx index ba6518f5..6b12325b 100644 --- a/app/[locale]/[slug]/page.tsx +++ b/app/[locale]/[slug]/page.tsx @@ -6,6 +6,7 @@ import { Metadata } from 'next'; import { getPageBySlug, getAllPages } from '@/lib/pages'; import { mdxComponents } from '@/components/blog/MDXComponents'; import { SITE_URL } from '@/lib/schema'; +import TrackedLink from '@/components/analytics/TrackedLink'; interface PageProps { params: Promise<{ @@ -38,11 +39,11 @@ export async function generateMetadata({ params }: PageProps): Promise title: pageData.frontmatter.title, description: pageData.frontmatter.excerpt || '', alternates: { - canonical: `/${locale}/${slug}`, + canonical: `${SITE_URL}/${locale}/${slug}`, languages: { - de: `/de/${slug}`, - en: `/en/${slug}`, - 'x-default': `/en/${slug}`, + de: `${SITE_URL}/de/${slug}`, + en: `${SITE_URL}/en/${slug}`, + 'x-default': `${SITE_URL}/en/${slug}`, }, }, openGraph: { @@ -110,15 +111,19 @@ export default async function StandardPage({ params }: PageProps) {

{t('needHelp')}

{t('supportTeamAvailable')}

- {t('contactUs')} - +
diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index be183cc9..7c4e6eca 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -30,11 +30,11 @@ export async function generateMetadata({ params }: BlogPostProps): Promise + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 6ae3390a..a388987d 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -85,11 +85,11 @@ export async function generateMetadata({ title, description, alternates: { - canonical: `/${locale}`, + canonical: `${SITE_URL}/${locale}`, languages: { - de: '/de', - en: '/en', - 'x-default': '/en', + de: `${SITE_URL}/de`, + en: `${SITE_URL}/en`, + 'x-default': `${SITE_URL}/en`, }, }, openGraph: { diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index 218794be..c9ba3d34 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -16,6 +16,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc'; import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; +import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; interface ProductPageProps { params: Promise<{ @@ -52,11 +53,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise
diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index e2cfe761..adf55e92 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl'; import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui'; import { sendContactFormAction } from '@/app/actions/contact'; import { useAnalytics } from '@/components/analytics/useAnalytics'; +import { AnalyticsEvents } from '@/components/analytics/analytics-events'; export default function ContactForm() { const t = useTranslations('Contact'); const { trackEvent } = useAnalytics(); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); + const [hasStarted, setHasStarted] = useState(false); + + const handleFocus = (fieldId: string) => { + // Initial form start + if (!hasStarted) { + setHasStarted(true); + trackEvent(AnalyticsEvents.FORM_START, { + form_id: 'contact_form', + form_name: 'Contact', + }); + } + + // Field-level transparency + trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, { + form_id: 'contact_form', + field_id: fieldId, + }); + }; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -29,10 +48,18 @@ export default function ContactForm() { (e.target as HTMLFormElement).reset(); } else { console.error('Contact form submission failed:', { email, error: result.error }); + trackEvent(AnalyticsEvents.FORM_ERROR, { + form_id: 'contact_form', + error: result.error || 'submission_failed', + }); setStatus('error'); } } catch (error) { console.error('Contact form submission error:', { email, error }); + trackEvent(AnalyticsEvents.FORM_ERROR, { + form_id: 'contact_form', + error: (error as Error).message || 'unexpected_error', + }); setStatus('error'); } } @@ -112,7 +139,7 @@ export default function ContactForm() { name="name" autoComplete="name" enterKeyHint="next" - placeholder={t('form.namePlaceholder')} + onFocus={() => handleFocus('name')} required /> @@ -126,6 +153,7 @@ export default function ContactForm() { inputMode="email" enterKeyHint="next" placeholder={t('form.emailPlaceholder')} + onFocus={() => handleFocus('email')} required /> @@ -137,6 +165,7 @@ export default function ContactForm() { rows={4} enterKeyHint="send" placeholder={t('form.messagePlaceholder')} + onFocus={() => handleFocus('message')} required /> diff --git a/components/DatasheetDownload.tsx b/components/DatasheetDownload.tsx index 49d8f374..75433ded 100644 --- a/components/DatasheetDownload.tsx +++ b/components/DatasheetDownload.tsx @@ -2,6 +2,8 @@ import { cn } from '@/components/ui/utils'; import { useTranslations } from 'next-intl'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; interface DatasheetDownloadProps { datasheetPath: string; @@ -10,34 +12,42 @@ interface DatasheetDownloadProps { export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) { const t = useTranslations('Products'); + const { trackEvent } = useAnalytics(); return ( -
- + + trackEvent(AnalyticsEvents.DOWNLOAD, { + file_name: datasheetPath.split('/').pop(), + file_path: datasheetPath, + location: 'product_page', + }) + } className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1" > {/* Animated Background Gradient */}
- + {/* Inner Content */}
{/* Icon Container */}
- -
@@ -45,7 +55,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee {/* Text Content */}
- PDF Datasheet + + PDF Datasheet +

{t('downloadDatasheet')} @@ -58,7 +70,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee {/* Arrow Icon */}
- +

diff --git a/components/Footer.tsx b/components/Footer.tsx index 9a2efc1c..7618b546 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,11 +1,16 @@ +'use client'; + import Link from 'next/link'; import Image from 'next/image'; import { useTranslations, useLocale } from 'next-intl'; import { Container } from './ui'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; export default function Footer() { const t = useTranslations('Footer'); const navT = useTranslations('Navigation'); + const { trackEvent } = useAnalytics(); const locale = useLocale(); const currentYear = new Date().getFullYear(); @@ -17,7 +22,16 @@ export default function Footer() {
{/* Brand Column */}
- + + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + target: 'home_logo', + location: 'footer', + }) + } + > {t('products')} + trackEvent(AnalyticsEvents.LINK_CLICK, { + type: 'social', + target: 'linkedin', + location: 'footer', + }) + } className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10" > LinkedIn @@ -54,6 +75,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: t('legalNotice'), + href: t('legalNoticeSlug'), + location: 'footer_legal', + }) + } > {t('legalNotice')} @@ -62,6 +90,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: t('privacyPolicy'), + href: t('privacyPolicySlug'), + location: 'footer_legal', + }) + } > {t('privacyPolicy')} @@ -70,6 +105,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: t('terms'), + href: t('termsSlug'), + location: 'footer_legal', + }) + } > {t('terms')} @@ -86,6 +128,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: navT('team'), + href: '/team', + location: 'footer_company', + }) + } > {navT('team')} @@ -94,6 +143,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: navT('products'), + href: locale === 'de' ? '/produkte' : '/products', + location: 'footer_company', + }) + } > {navT('products')} @@ -102,6 +158,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: navT('blog'), + href: '/blog', + location: 'footer_company', + }) + } > {navT('blog')} @@ -110,6 +173,13 @@ export default function Footer() { + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: navT('contact'), + href: '/contact', + location: 'footer_company', + }) + } > {navT('contact')} @@ -146,7 +216,17 @@ export default function Footer() { }, ].map((post, i) => (
  • - + + trackEvent(AnalyticsEvents.BLOG_POST_VIEW, { + title: post.title, + slug: post.slug, + location: 'footer_recent', + }) + } + >

    {post.title}

    @@ -163,10 +243,34 @@ export default function Footer() {

    {t('copyright', { year: currentYear })}

    - + + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: locale, + to: 'en', + location: 'footer', + }) + } + > English - + + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: locale, + to: 'de', + location: 'footer', + }) + } + > Deutsch
    diff --git a/components/Header.tsx b/components/Header.tsx index 44122c7f..e365f4e6 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -8,10 +8,13 @@ import { usePathname } from 'next/navigation'; import { Button } from './ui'; import { useEffect, useState } from 'react'; import { cn } from './ui'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; export default function Header() { const t = useTranslations('Navigation'); const pathname = usePathname(); + const { trackEvent } = useAnalytics(); const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -79,7 +82,15 @@ export default function Header() { animate={{ scale: 1, opacity: 1 }} transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }} > - + + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + target: 'home_logo', + location: 'header', + }) + } + > {t('home')} setIsMobileMenuOpen(false)} + onClick={() => { + setIsMobileMenuOpen(false); + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: item.label, + href: item.href, + location: 'header_nav', + }); + }} className={cn( textColorClass, 'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5', @@ -139,6 +157,14 @@ export default function Header() { > + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: currentLocale, + to: 'en', + location: 'header', + }) + } className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`} > EN @@ -157,6 +183,14 @@ export default function Header() { > + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: currentLocale, + to: 'de', + location: 'header', + }) + } className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`} > DE @@ -174,6 +208,12 @@ export default function Header() { variant="white" size="md" className="px-8 shadow-xl" + onClick={() => + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + label: t('contact'), + location: 'header_cta', + }) + } > {t('contact')} @@ -196,7 +236,14 @@ export default function Header() { damping: 20, delay: 0.5, }} - onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} + onClick={() => { + const newState = !isMobileMenuOpen; + setIsMobileMenuOpen(newState); + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + type: 'mobile_menu', + action: newState ? 'open' : 'close', + }); + }} > setIsMobileMenuOpen(false)} + onClick={() => { + setIsMobileMenuOpen(false); + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: item.label, + href: item.href, + location: 'mobile_menu', + }); + }} className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4" > {item.label} diff --git a/components/RelatedProducts.tsx b/components/RelatedProducts.tsx index 1d582022..a7bbb39b 100644 --- a/components/RelatedProducts.tsx +++ b/components/RelatedProducts.tsx @@ -1,8 +1,13 @@ +'use client'; + import { getAllProducts } from '@/lib/mdx'; import { mapFileSlugToTranslated } from '@/lib/slugs'; import { getTranslations } from 'next-intl/server'; import Image from 'next/image'; import Link from 'next/link'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; +import { useEffect, useState } from 'react'; interface RelatedProductsProps { currentSlug: string; @@ -10,15 +15,28 @@ interface RelatedProductsProps { locale: string; } -export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) { - const allProducts = await getAllProducts(locale); - const t = await getTranslations('Products'); +export default function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) { + const { trackEvent } = useAnalytics(); + const [allProducts, setAllProducts] = useState([]); + const [t, setT] = useState(null); + + useEffect(() => { + async function load() { + const products = await getAllProducts(locale); + const translations = await getTranslations('Products'); + setAllProducts(products); + setT(() => translations); + } + load(); + }, [locale]); + + if (!t) return null; // Filter products: same category, not current product const related = allProducts - .filter(p => - p.slug !== currentSlug && - p.frontmatter.categories.some(cat => categories.includes(cat)) + .filter( + (p) => + p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)), ) .slice(0, 3); // Limit to 3 for better spacing @@ -36,26 +54,43 @@ export default async function RelatedProducts({ currentSlug, categories, locale
    - {related.map(async (product) => { + {related.map((product) => { // Find the category slug for the link - const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables']; - const catSlug = categorySlugs.find(slug => { - const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase()); - const title = t(`categories.${key}.title`); - return product.frontmatter.categories.some(cat => - cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title - ); - }) || 'low-voltage-cables'; + const categorySlugs = [ + 'low-voltage-cables', + 'medium-voltage-cables', + 'high-voltage-cables', + 'solar-cables', + ]; + const catSlug = + categorySlugs.find((slug) => { + const key = slug + .replace(/-cables$/, '') + .replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + const title = t(`categories.${key}.title`); + return product.frontmatter.categories.some( + (cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title, + ); + }) || 'low-voltage-cables'; - const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale); - const translatedCategorySlug = await mapFileSlugToTranslated(catSlug, locale); - const productsBase = await mapFileSlugToTranslated('products', locale); + // Note: Since this is now client-side, we can't easily use mapFileSlugToTranslated + // if it's a server-only lib. Let's assume for now the slugs are compatible or + // we'll need to pass translated slugs from parent if needed. + // For now, let's just use the product slug as is, or if we want to be safe, + // we should have kept this a server component and used a client wrapper for the link. return ( + trackEvent(AnalyticsEvents.PRODUCT_VIEW, { + product_id: product.slug, + product_name: product.frontmatter.title, + location: 'related_products', + }) + } >
    {product.frontmatter.images?.[0] ? ( @@ -76,8 +111,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
    - {product.frontmatter.categories.slice(0, 1).map((cat, idx) => ( - + {product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => ( + {cat} ))} @@ -89,8 +127,18 @@ export default async function RelatedProducts({ currentSlug, categories, locale {t('details')} - - + +
    diff --git a/components/RequestQuoteForm.tsx b/components/RequestQuoteForm.tsx index 04aef247..e86e57a4 100644 --- a/components/RequestQuoteForm.tsx +++ b/components/RequestQuoteForm.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { Input, Textarea, Button } from '@/components/ui'; import { sendContactFormAction } from '@/app/actions/contact'; import { useAnalytics } from '@/components/analytics/useAnalytics'; +import { AnalyticsEvents } from '@/components/analytics/analytics-events'; interface RequestQuoteFormProps { productName: string; @@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) const [email, setEmail] = useState(''); const [request, setRequest] = useState(''); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); + const [hasStarted, setHasStarted] = useState(false); + + const handleFocus = (fieldId: string) => { + // Initial form start + if (!hasStarted) { + setHasStarted(true); + trackEvent(AnalyticsEvents.FORM_START, { + form_id: 'quote_request_form', + form_name: 'Product Quote Inquiry', + product_name: productName, + }); + } + + // Field-level transparency + trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, { + form_id: 'quote_request_form', + field_id: fieldId, + product_name: productName, + }); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -39,10 +60,20 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) setEmail(''); setRequest(''); } else { + trackEvent(AnalyticsEvents.FORM_ERROR, { + form_id: 'quote_request_form', + product_name: productName, + error: result.error || 'submission_failed', + }); setStatus('error'); } } catch (error) { console.error('Form submission error:', error); + trackEvent(AnalyticsEvents.FORM_ERROR, { + form_id: 'quote_request_form', + product_name: productName, + error: (error as Error).message || 'unexpected_error', + }); setStatus('error'); } }; @@ -131,6 +162,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) required value={email} onChange={(e) => setEmail(e.target.value)} + onFocus={() => handleFocus('email')} placeholder={t('email')} className="h-9 text-xs !mt-0" /> @@ -143,6 +175,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) rows={3} value={request} onChange={(e) => setRequest(e.target.value)} + onFocus={() => handleFocus('request')} placeholder={t('message')} className="text-xs !mt-0" /> diff --git a/components/analytics/ProductEngagementTracker.tsx b/components/analytics/ProductEngagementTracker.tsx new file mode 100644 index 00000000..0b7b1c88 --- /dev/null +++ b/components/analytics/ProductEngagementTracker.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAnalytics } from './useAnalytics'; +import { AnalyticsEvents } from './analytics-events'; + +interface ProductEngagementTrackerProps { + productName: string; + productSlug: string; + categories: string[]; + sku?: string; +} + +/** + * ProductEngagementTracker + * Deep analytics for product pages. + * Tracks specific view events with full metadata for sales analysis. + */ +export default function ProductEngagementTracker({ + productName, + productSlug, + categories, + sku, +}: ProductEngagementTrackerProps) { + const { trackEvent } = useAnalytics(); + + useEffect(() => { + // Standardized product view event for "High-Fidelity" sales insights + trackEvent(AnalyticsEvents.PRODUCT_VIEW, { + product_id: productSlug, + product_name: productName, + product_sku: sku, + product_categories: categories.join(', '), + location: 'pdp_standard', + }); + + // We can also track "Engagement Start" to measure dwell time later + const startTime = Date.now(); + + return () => { + const dwellTime = Math.round((Date.now() - startTime) / 1000); + trackEvent('pdp_dwell_time', { + product_id: productSlug, + seconds: dwellTime, + }); + }; + }, [productName, productSlug, categories, sku, trackEvent]); + + return null; +} diff --git a/components/analytics/ScrollDepthTracker.tsx b/components/analytics/ScrollDepthTracker.tsx new file mode 100644 index 00000000..7a452f63 --- /dev/null +++ b/components/analytics/ScrollDepthTracker.tsx @@ -0,0 +1,62 @@ +'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 default 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) + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [pathname, trackEvent]); + + return null; +} diff --git a/components/analytics/TrackedButton.tsx b/components/analytics/TrackedButton.tsx new file mode 100644 index 00000000..23ea7db9 --- /dev/null +++ b/components/analytics/TrackedButton.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react'; +import { Button, ButtonProps } from '../ui/Button'; +import { useAnalytics } from './useAnalytics'; +import { AnalyticsEvents } from './analytics-events'; + +interface TrackedButtonProps extends ButtonProps { + eventName?: string; + eventProperties?: Record; +} + +/** + * A wrapper around the project's Button component that tracks click events. + * Safe to use in server components. + */ +export default 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 ; * } * ``` @@ -31,6 +31,7 @@ export const AnalyticsEvents = { PAGE_VIEW: 'pageview', PAGE_SCROLL: 'page_scroll', PAGE_EXIT: 'page_exit', + SCROLL_DEPTH: 'scroll_depth', // User Interaction Events BUTTON_CLICK: 'button_click', @@ -38,6 +39,7 @@ export const AnalyticsEvents = { FORM_SUBMIT: 'form_submit', FORM_START: 'form_start', FORM_ERROR: 'form_error', + FORM_FIELD_FOCUS: 'form_field_focus', // E-commerce Events PRODUCT_VIEW: 'product_view', @@ -46,6 +48,7 @@ export const AnalyticsEvents = { PRODUCT_PURCHASE: 'product_purchase', PRODUCT_WISHLIST_ADD: 'product_wishlist_add', PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove', + PRODUCT_TAB_SWITCH: 'product_tab_switch', // Search & Filter Events SEARCH: 'search', @@ -71,6 +74,7 @@ export const AnalyticsEvents = { TOGGLE_SWITCH: 'toggle_switch', ACCORDION_TOGGLE: 'accordion_toggle', TAB_SWITCH: 'tab_switch', + TOC_CLICK: 'toc_click', // Error & Performance Events ERROR: 'error', diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 903d5cf2..44fa1f14 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -5,11 +5,14 @@ import { Button, Container, Heading, Section } from '@/components/ui'; import { motion } from 'framer-motion'; import { useTranslations, useLocale } from 'next-intl'; import dynamic from 'next/dynamic'; +import { useAnalytics } from '../analytics/useAnalytics'; +import { AnalyticsEvents } from '../analytics/analytics-events'; const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); export default function Hero() { const t = useTranslations('Home.hero'); const locale = useLocale(); + const { trackEvent } = useAnalytics(); return (
    @@ -60,6 +63,12 @@ export default function Hero() { variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg" + onClick={() => + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + label: t('cta'), + location: 'home_hero_primary', + }) + } > {t('cta')} @@ -71,6 +80,12 @@ export default function Hero() { variant="white" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none" + onClick={() => + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + label: t('exploreProducts'), + location: 'home_hero_secondary', + }) + } > {t('exploreProducts')}