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'
This commit is contained in:
2026-02-16 18:30:29 +01:00
parent 52b17423dd
commit 0e089f9471
22 changed files with 634 additions and 98 deletions

View File

@@ -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<HTMLFormElement>) {
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
/>
</div>
@@ -126,6 +153,7 @@ export default function ContactForm() {
inputMode="email"
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('email')}
required
/>
</div>
@@ -137,6 +165,7 @@ export default function ContactForm() {
rows={4}
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('message')}
required
/>
</div>

View File

@@ -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 (
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
<a
href={datasheetPath}
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
<a
href={datasheetPath}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
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 */}
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
@@ -45,7 +55,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Text Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet
</span>
</div>
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')}
@@ -58,7 +70,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Arrow Icon */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>

View File

@@ -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() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
{/* Brand Column */}
<div className="lg:col-span-4 space-y-8">
<Link href={`/${locale}`} className="inline-block group">
<Link
href={`/${locale}`}
className="inline-block group"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'footer',
})
}
>
<Image
src="/logo-white.svg"
alt={t('products')}
@@ -34,6 +48,13 @@ export default function Footer() {
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
target="_blank"
rel="noopener noreferrer"
onClick={() =>
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"
>
<span className="sr-only">LinkedIn</span>
@@ -54,6 +75,13 @@ export default function Footer() {
<Link
href={`/${locale}/${t('legalNoticeSlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('legalNotice'),
href: t('legalNoticeSlug'),
location: 'footer_legal',
})
}
>
{t('legalNotice')}
</Link>
@@ -62,6 +90,13 @@ export default function Footer() {
<Link
href={`/${locale}/${t('privacyPolicySlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('privacyPolicy'),
href: t('privacyPolicySlug'),
location: 'footer_legal',
})
}
>
{t('privacyPolicy')}
</Link>
@@ -70,6 +105,13 @@ export default function Footer() {
<Link
href={`/${locale}/${t('termsSlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('terms'),
href: t('termsSlug'),
location: 'footer_legal',
})
}
>
{t('terms')}
</Link>
@@ -86,6 +128,13 @@ export default function Footer() {
<Link
href={`/${locale}/team`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('team'),
href: '/team',
location: 'footer_company',
})
}
>
{navT('team')}
</Link>
@@ -94,6 +143,13 @@ export default function Footer() {
<Link
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('products'),
href: locale === 'de' ? '/produkte' : '/products',
location: 'footer_company',
})
}
>
{navT('products')}
</Link>
@@ -102,6 +158,13 @@ export default function Footer() {
<Link
href={`/${locale}/blog`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('blog'),
href: '/blog',
location: 'footer_company',
})
}
>
{navT('blog')}
</Link>
@@ -110,6 +173,13 @@ export default function Footer() {
<Link
href={`/${locale}/contact`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('contact'),
href: '/contact',
location: 'footer_company',
})
}
>
{navT('contact')}
</Link>
@@ -146,7 +216,17 @@ export default function Footer() {
},
].map((post, i) => (
<li key={i}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block text-white/80"
onClick={() =>
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
title: post.title,
slug: post.slug,
location: 'footer_recent',
})
}
>
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
{post.title}
</p>
@@ -163,10 +243,34 @@ export default function Footer() {
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
<Link href="/en" locale="en" className="hover:text-white transition-colors">
<Link
href="/en"
locale="en"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: locale,
to: 'en',
location: 'footer',
})
}
>
English
</Link>
<Link href="/de" locale="de" className="hover:text-white transition-colors">
<Link
href="/de"
locale="de"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: locale,
to: 'de',
location: 'footer',
})
}
>
Deutsch
</Link>
</div>

View File

@@ -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 }}
>
<Link href={`/${currentLocale}`}>
<Link
href={`/${currentLocale}`}
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'header',
})
}
>
<Image
src={logoSrc}
alt={t('home')}
@@ -109,7 +120,14 @@ export default function Header() {
<motion.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => 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() {
>
<Link
href={getPathForLocale('en')}
onClick={() =>
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() {
>
<Link
href={getPathForLocale('de')}
onClick={() =>
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')}
</Button>
@@ -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',
});
}}
>
<motion.svg
className="w-7 h-7"
@@ -274,7 +321,14 @@ export default function Header() {
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => 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}

View File

@@ -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<any[]>([]);
const [t, setT] = useState<any>(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
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{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 (
<Link
key={product.slug}
href={`/${locale}/${productsBase}/${translatedCategorySlug}/${translatedProductSlug}`}
href={`/${locale}/products/${catSlug}/${product.slug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
onClick={() =>
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: product.slug,
product_name: product.frontmatter.title,
location: 'related_products',
})
}
>
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
{product.frontmatter.images?.[0] ? (
@@ -76,8 +111,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="p-8">
<div className="flex flex-wrap gap-2 mb-3">
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
<span
key={idx}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
@@ -89,8 +127,18 @@ export default async function RelatedProducts({ currentSlug, categories, locale
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
{t('details')}
</span>
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
<svg
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>

View File

@@ -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"
/>

View File

@@ -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;
}

View File

@@ -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<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)
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [pathname, trackEvent]);
return null;
}

View File

@@ -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<string, any>;
}
/**
* 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<HTMLButtonElement>) => {
trackEvent(eventName, {
...eventProperties,
label: typeof props.children === 'string' ? props.children : eventProperties.label,
});
if (onClick) onClick(e);
};
return <Button {...props} onClick={handleClick} />;
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface TrackedLinkProps {
href: string;
eventName?: string;
eventProperties?: Record<string, any>;
className?: string;
children: React.ReactNode;
onClick?: () => void;
}
/**
* A wrapper around next/link that tracks the click event.
* Useful for adding tracking to server components.
*/
export default function TrackedLink({
href,
eventName = AnalyticsEvents.LINK_CLICK,
eventProperties = {},
className,
children,
onClick,
}: TrackedLinkProps) {
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => {
trackEvent(eventName, {
href,
...eventProperties,
});
if (onClick) onClick();
};
return (
<Link href={href} className={className} onClick={handleClick}>
{children}
</Link>
);
}

View File

@@ -1,18 +1,18 @@
/**
* Analytics Events Utility
*
*
* Centralized definitions for common analytics events and their properties.
* This helps maintain consistency across the application and makes it easier
* to track meaningful events.
*
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
*
*
* function ProductPage() {
* const { trackEvent } = useAnalytics();
*
*
* const handleAddToCart = (productId: string, productName: string) => {
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
* product_id: productId,
@@ -20,7 +20,7 @@
* page: 'product-detail'
* });
* };
*
*
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
* }
* ```
@@ -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',

View File

@@ -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 (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
@@ -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')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
@@ -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')}
</Button>