Compare commits
14 Commits
v1.2.2
...
v1.2.7-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a |
@@ -10,17 +10,31 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 🔐 Configure Private Registry
|
||||
run: |
|
||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||
|
||||
@@ -154,14 +154,26 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
@@ -202,12 +214,12 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2,mode=max
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||
|
||||
@@ -254,7 +266,7 @@ jobs:
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -321,7 +333,7 @@ jobs:
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
@@ -386,14 +398,26 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
@@ -418,14 +442,26 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,4 +11,6 @@ lighthouserc.cjs
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
!directus/migrations/
|
||||
|
||||
.next-docker
|
||||
@@ -8,7 +8,6 @@ ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
@@ -25,7 +24,7 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
@@ -69,13 +70,24 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(post.content)}
|
||||
/>
|
||||
{(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && (
|
||||
<div className="bg-orange-500 text-white text-center py-2 px-4 font-bold text-sm tracking-wider uppercase relative z-50">
|
||||
Preview (Not visible in production)
|
||||
</div>
|
||||
)}
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||
|
||||
{/* Title overlay on image */}
|
||||
@@ -84,18 +96,18 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<div className="max-w-4xl">
|
||||
{post.frontmatter.category && (
|
||||
<div className="overflow-hidden mb-6">
|
||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
|
||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||
{post.frontmatter.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
||||
className="text-white mb-8 drop-shadow-2xl"
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
@@ -145,7 +157,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<div className="sticky-narrative-content">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{post.frontmatter.excerpt && (
|
||||
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
|
||||
<div className="mb-16">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
@@ -153,7 +165,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
)}
|
||||
|
||||
{/* Main content with enhanced styling */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||
<MDXRemote source={post.content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,9 +75,16 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">
|
||||
{t('featuredPost')}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||
{featuredPost &&
|
||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||
featuredPost.frontmatter.public === false) && (
|
||||
<Badge variant="accent" className="bg-orange-500 text-white border-none">
|
||||
Preview
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
@@ -168,6 +175,15 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="absolute top-3 right-3 md:top-6 md:right-6 shadow-lg bg-orange-500 text-white border-none"
|
||||
>
|
||||
Preview
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
||||
|
||||
@@ -81,7 +81,16 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
|
||||
const clientKeys = [
|
||||
'Footer',
|
||||
'Navigation',
|
||||
'Contact',
|
||||
'Products',
|
||||
'Team',
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
if (messages[key]) {
|
||||
|
||||
39
chunk-analysis.json
Normal file
39
chunk-analysis.json
Normal file
@@ -0,0 +1,39 @@
|
||||
.next/static/chunks/1555f2949dbcddff.js
|
||||
.next/static/chunks/180f5dcf81481335.js
|
||||
.next/static/chunks/268553c5293137f5.js
|
||||
.next/static/chunks/2d7ae32de68a39ef.js
|
||||
.next/static/chunks/2ec0936da0321266.js
|
||||
.next/static/chunks/37f7b54a37295c30.js
|
||||
.next/static/chunks/3e3942369abf2ddc.js
|
||||
.next/static/chunks/424d0a83ac1f43b8.js
|
||||
.next/static/chunks/47f749213f3cceab.js
|
||||
.next/static/chunks/487d683c339d19a3.js
|
||||
.next/static/chunks/535c1ab943e23448.js
|
||||
.next/static/chunks/558d909c3c1972b3.js
|
||||
.next/static/chunks/6cf611207add5a99.js
|
||||
.next/static/chunks/7bd9cb4fe778d0a3.js
|
||||
.next/static/chunks/817ca2bc66023675.js
|
||||
.next/static/chunks/83318e1ba94652b0.js
|
||||
.next/static/chunks/882400359e57d35e.js
|
||||
.next/static/chunks/91829f600ae9b629.js
|
||||
.next/static/chunks/98b8bfc9eb444163.js
|
||||
.next/static/chunks/9aed5432afcf0f2d.js
|
||||
.next/static/chunks/a1d67bb574863461.js
|
||||
.next/static/chunks/a6dad97d9634a72d.js
|
||||
.next/static/chunks/a71a8075e35ac509.js
|
||||
.next/static/chunks/afc79511da623949.js
|
||||
.next/static/chunks/b5c2a6630c37e020.js
|
||||
.next/static/chunks/bcded4d8b49a0260.js
|
||||
.next/static/chunks/c2f4c81be736500f.js
|
||||
.next/static/chunks/c4008b16a5e99b90.js
|
||||
.next/static/chunks/c98e4e432699368d.js
|
||||
.next/static/chunks/c9e37d1e4c73c7f0.js
|
||||
.next/static/chunks/d79d122fe6c2ca13.js
|
||||
.next/static/chunks/db3ea84e87f72a70.js
|
||||
.next/static/chunks/e39a48c430164d16.js
|
||||
.next/static/chunks/e58096c0ead62031.js
|
||||
.next/static/chunks/ed779de026be3e39.js
|
||||
.next/static/chunks/f7b46fbdaad1733e.js
|
||||
.next/static/chunks/fa833b5e3015d34f.js
|
||||
.next/static/chunks/fc4f94c4cc594aa4.js
|
||||
.next/static/chunks/turbopack-5e3bd8a685a47b49.js
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
@@ -18,7 +17,6 @@ export default function Header() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
@@ -35,7 +33,6 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
@@ -102,9 +99,9 @@ export default function Header() {
|
||||
];
|
||||
|
||||
const headerClass = cn(
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu animate-in fade-in slide-in-from-top-12 fill-mode-both',
|
||||
{
|
||||
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
},
|
||||
);
|
||||
@@ -114,263 +111,41 @@ export default function Header() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<m.header
|
||||
className={headerClass}
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||
<m.div
|
||||
className="flex-shrink-0 group touch-target"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||
<div
|
||||
className="flex-shrink-0 group touch-target animate-in fade-in zoom-in-90 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '100ms' }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="flex items-center gap-4 md:gap-12"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||
{menuItems.map((item, _idx) => (
|
||||
<m.div key={item.href} variants={navLinkVariants}>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||
</Link>
|
||||
</m.div>
|
||||
))}
|
||||
</m.nav>
|
||||
|
||||
<m.div
|
||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||
variants={headerRightVariants}
|
||||
>
|
||||
<m.div
|
||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.65 }}
|
||||
>
|
||||
<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
|
||||
</Link>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="w-px h-4 bg-current opacity-20"
|
||||
initial={{ scaleY: 0 }}
|
||||
animate={{ scaleY: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.75 }}
|
||||
>
|
||||
<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
|
||||
</Link>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<m.button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
delay: 0.5,
|
||||
}}
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<m.svg
|
||||
className="w-7 h-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.6 }}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
) : (
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
)}
|
||||
</m.svg>
|
||||
</m.button>
|
||||
</m.div>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
className="flex items-center gap-4 md:gap-12"
|
||||
>
|
||||
<m.nav
|
||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||
initial="closed"
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
variants={{
|
||||
open: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<nav className="hidden lg:flex items-center space-x-10">
|
||||
{menuItems.map((item, idx) => (
|
||||
<m.div
|
||||
<div
|
||||
key={item.href}
|
||||
variants={{
|
||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
||||
open: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeOut',
|
||||
delay: idx * 0.08,
|
||||
},
|
||||
},
|
||||
}}
|
||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
@@ -379,128 +154,214 @@ export default function Header() {
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||
</Link>
|
||||
</m.div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<m.div
|
||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
<div
|
||||
className={cn('hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both', textColorClass)}
|
||||
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
||||
>
|
||||
<m.div
|
||||
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.9 }}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 1.0 }}
|
||||
<div>
|
||||
<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'}`}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="w-px h-6 bg-white/20"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4, delay: 1.05 }}
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-current opacity-20" />
|
||||
<div>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
|
||||
textColorClass,
|
||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100'
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-7 h-7 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 1.1 }}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<m.div
|
||||
className="p-12 flex justify-center opacity-20"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.5, delay: 1.4 }}
|
||||
>
|
||||
<m.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</m.div>
|
||||
</m.div>
|
||||
</m.nav>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</m.header>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className={cn('transition-all duration-500 transform', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')}
|
||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
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}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn('pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||
>
|
||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/20" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<div
|
||||
className={cn('p-12 flex justify-center transition-all duration-700', isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75')}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const navVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const navLinkVariants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeOut',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headerRightVariants = {
|
||||
hidden: { opacity: 0, x: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease: 'easeOut' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
interface LightboxProps {
|
||||
@@ -139,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
interface ScribbleProps {
|
||||
@@ -11,67 +10,49 @@ interface ScribbleProps {
|
||||
}
|
||||
|
||||
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
||||
const pathVariants: Variants = {
|
||||
hidden: { pathLength: 0, opacity: 0 },
|
||||
visible: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 1.8,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (variant === 'circle') {
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<m.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
</LazyMotion>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<m.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</LazyMotion>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,18 +136,14 @@ function AddToCartButton({ product, quantity = 1 }) {
|
||||
product_category: product.category,
|
||||
price: product.price,
|
||||
quantity: quantity,
|
||||
cart_total: 150.00, // Current cart total
|
||||
cart_total: 150.0, // Current cart total
|
||||
});
|
||||
|
||||
// Actual add to cart logic
|
||||
// addToCart(product, quantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleAddToCart}>
|
||||
Add to Cart
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -171,7 +167,7 @@ function CheckoutComplete({ order }) {
|
||||
transaction_tax: order.tax,
|
||||
transaction_shipping: order.shipping,
|
||||
product_count: order.items.length,
|
||||
products: order.items.map(item => ({
|
||||
products: order.items.map((item) => ({
|
||||
product_id: item.product.id,
|
||||
product_name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
@@ -198,27 +194,21 @@ function WishlistButton({ product }) {
|
||||
|
||||
const toggleWishlist = () => {
|
||||
const newState = !isInWishlist;
|
||||
|
||||
|
||||
trackEvent(
|
||||
newState
|
||||
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
|
||||
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||
{
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
product_category: product.category,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
setIsInWishlist(newState);
|
||||
// Update wishlist in backend
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={toggleWishlist}>
|
||||
{isInWishlist ? '❤️' : '🤍'}
|
||||
</button>
|
||||
);
|
||||
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -268,7 +258,7 @@ function ContactForm() {
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
@@ -310,9 +300,7 @@ function NewsletterSignup() {
|
||||
return (
|
||||
<div>
|
||||
<input placeholder="Enter email" />
|
||||
<button onClick={() => handleSubscribe('user@example.com')}>
|
||||
Subscribe
|
||||
</button>
|
||||
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -396,10 +384,12 @@ function LoginForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin('user@example.com', 'password');
|
||||
}}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin('user@example.com', 'password');
|
||||
}}
|
||||
>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
@@ -418,11 +408,7 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
function SignupForm() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleSignup = (userData: {
|
||||
email: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
}) => {
|
||||
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
|
||||
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
||||
user_email: userData.email,
|
||||
user_name: userData.name,
|
||||
@@ -436,14 +422,16 @@ function SignupForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignup({
|
||||
email: 'user@example.com',
|
||||
name: 'John Doe',
|
||||
company: 'ACME Corp',
|
||||
});
|
||||
}}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignup({
|
||||
email: 'user@example.com',
|
||||
name: 'John Doe',
|
||||
company: 'ACME Corp',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
@@ -483,7 +471,7 @@ function SearchBar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
@@ -549,7 +537,7 @@ function ProductFilters() {
|
||||
<option value="cables">Cables</option>
|
||||
<option value="connectors">Connectors</option>
|
||||
</select>
|
||||
|
||||
|
||||
<button onClick={handleClearFilters}>Clear Filters</button>
|
||||
</div>
|
||||
);
|
||||
@@ -631,11 +619,7 @@ function VideoPlayer({ videoId, videoTitle }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<video
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handleComplete}
|
||||
>
|
||||
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
|
||||
<source src="/video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
@@ -665,11 +649,7 @@ function DownloadButton({ fileName, fileType, fileSize }) {
|
||||
// window.location.href = `/downloads/${fileName}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDownload}>
|
||||
Download {fileName}
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleDownload}>Download {fileName}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -700,7 +680,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps> {
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
error_message: error.message,
|
||||
error_stack: error.stack,
|
||||
@@ -742,14 +722,14 @@ function ApiClient() {
|
||||
const fetchData = async (endpoint: string) => {
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
trackEvent(AnalyticsEvents.API_ERROR, {
|
||||
endpoint: endpoint,
|
||||
status_code: response.status,
|
||||
error_message: response.statusText,
|
||||
});
|
||||
|
||||
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -765,7 +745,7 @@ function ApiClient() {
|
||||
error_message: error.message,
|
||||
error_type: error.name,
|
||||
});
|
||||
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -963,15 +943,9 @@ function CableProductPage({ cable }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{cable.name}</h1>
|
||||
<button onClick={handleTechnicalSpecDownload}>
|
||||
Download Technical Specs
|
||||
</button>
|
||||
<button onClick={handleRequestQuote}>
|
||||
Request Quote
|
||||
</button>
|
||||
<button onClick={handleBrochureDownload}>
|
||||
Download Brochure
|
||||
</button>
|
||||
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
|
||||
<button onClick={handleRequestQuote}>Request Quote</button>
|
||||
<button onClick={handleBrochureDownload}>Download Brochure</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1010,12 +984,8 @@ function WindFarmProjectPage({ project }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
<button onClick={handleProjectInquiry}>
|
||||
Request Project Consultation
|
||||
</button>
|
||||
<button onClick={handleCableCalculation}>
|
||||
Calculate Cable Requirements
|
||||
</button>
|
||||
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
|
||||
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1066,7 +1036,7 @@ test('tracks button click', () => {
|
||||
// [Umami] Tracked pageview: /products/123
|
||||
|
||||
// To test without sending data to Umami:
|
||||
// 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
|
||||
// 1. Remove UMAMI_WEBSITE_ID from .env
|
||||
// 2. Or set it to an empty string
|
||||
// 3. Check console logs to verify events are being tracked
|
||||
```
|
||||
@@ -1169,7 +1139,9 @@ function WebVitalsTracker() {
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
|
||||
observer.observe({
|
||||
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1194,6 +1166,7 @@ This examples file demonstrates how to implement comprehensive analytics trackin
|
||||
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
||||
|
||||
Remember to:
|
||||
|
||||
1. Use the `useAnalytics` hook for client-side tracking
|
||||
2. Import events from `AnalyticsEvents` for consistency
|
||||
3. Include relevant context in your events
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Setup Checklist
|
||||
|
||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
||||
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||
- [ ] Verify `UmamiScript` is in your layout
|
||||
- [ ] Verify `AnalyticsProvider` is in your layout
|
||||
- [ ] Test in development mode
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
```bash
|
||||
# Required
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
|
||||
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
@@ -86,16 +86,16 @@ function ProductCard({ product }) {
|
||||
|
||||
## Common Events
|
||||
|
||||
| Event | When to Use | Example Properties |
|
||||
|-------|-------------|-------------------|
|
||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
| Event | When to Use | Example Properties |
|
||||
| --------------------- | ------------------- | ------------------------------------------------- |
|
||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -112,7 +112,7 @@ In development, you'll see console logs:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -120,8 +120,9 @@ In development, you'll see console logs:
|
||||
### Analytics Not Working?
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify script is loading:**
|
||||
@@ -136,12 +137,12 @@ In development, you'll see console logs:
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Performance Tips
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Add these to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Required: Your Umami website ID
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
|
||||
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||
```
|
||||
|
||||
@@ -75,11 +75,7 @@ function ProductCard({ product }) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleAddToCart}>
|
||||
Add to Cart
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,7 +92,7 @@ function CustomNavigation() {
|
||||
const navigateToCustomPage = () => {
|
||||
// Track a custom pageview
|
||||
trackPageview('/custom-path?param=value');
|
||||
|
||||
|
||||
// Then perform navigation
|
||||
window.location.href = '/custom-path?param=value';
|
||||
};
|
||||
@@ -277,11 +273,7 @@ function ErrorBoundary({ children }) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary onError={handleError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -289,20 +281,20 @@ function ErrorBoundary({ children }) {
|
||||
|
||||
### Common Events
|
||||
|
||||
| Event Name | Description | Example Properties |
|
||||
|------------|-------------|-------------------|
|
||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
| Event Name | Description | Example Properties |
|
||||
| --------------------- | --------------------- | ------------------------------------------------------------ |
|
||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
|
||||
### Custom Events
|
||||
|
||||
@@ -385,8 +377,9 @@ The analytics system includes development mode logging:
|
||||
### Analytics Not Working
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify the script is loading:**
|
||||
@@ -405,11 +398,11 @@ In development mode, you'll see console logs for all tracked events. This helps
|
||||
|
||||
### Disabling Analytics
|
||||
|
||||
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
|
||||
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable:
|
||||
|
||||
```bash
|
||||
# .env.local (not committed to git)
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Performance
|
||||
@@ -438,6 +431,7 @@ The analytics implementation is optimized for performance:
|
||||
## Support
|
||||
|
||||
For issues or questions about the analytics implementation, check:
|
||||
|
||||
1. This README for usage examples
|
||||
2. The component source code for implementation details
|
||||
3. The Umami documentation for platform-specific questions
|
||||
|
||||
@@ -16,6 +16,7 @@ The project already had a solid foundation:
|
||||
## What Was Enhanced
|
||||
|
||||
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||
|
||||
- ✅ Added TypeScript props interface for customization
|
||||
- ✅ Added JSDoc documentation with usage examples
|
||||
- ✅ Added error handling for script loading failures
|
||||
@@ -23,11 +24,13 @@ The project already had a solid foundation:
|
||||
- ✅ Improved type safety and comments
|
||||
|
||||
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||
|
||||
- ✅ Added comprehensive JSDoc documentation
|
||||
- ✅ Added development mode logging
|
||||
- ✅ Improved code comments
|
||||
|
||||
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||
|
||||
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||
- ✅ `trackEvent()` method for custom events
|
||||
- ✅ `trackPageview()` method for manual pageview tracking
|
||||
@@ -35,12 +38,14 @@ The project already had a solid foundation:
|
||||
- ✅ Development mode logging
|
||||
|
||||
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||
|
||||
- ✅ Centralized event constants for consistency
|
||||
- ✅ Type-safe event names
|
||||
- ✅ Helper functions for common event properties
|
||||
- ✅ 30+ predefined events for various use cases
|
||||
|
||||
### 5. **Comprehensive Documentation**
|
||||
|
||||
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||
@@ -63,12 +68,14 @@ components/analytics/
|
||||
## Key Features
|
||||
|
||||
### 🚀 Modern Implementation
|
||||
|
||||
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||
- TypeScript for type safety
|
||||
- React hooks for clean API
|
||||
- Environment variable configuration
|
||||
|
||||
### 📊 Comprehensive Tracking
|
||||
|
||||
- Automatic pageview tracking on route changes
|
||||
- Custom event tracking with properties
|
||||
- E-commerce events (products, cart, purchases)
|
||||
@@ -77,6 +84,7 @@ components/analytics/
|
||||
- Error and performance tracking
|
||||
|
||||
### 🎯 Developer Experience
|
||||
|
||||
- Type-safe event tracking
|
||||
- Centralized event definitions
|
||||
- Development mode logging
|
||||
@@ -84,6 +92,7 @@ components/analytics/
|
||||
- 20+ practical examples
|
||||
|
||||
### 🔒 Privacy & Performance
|
||||
|
||||
- No PII tracking by default
|
||||
- Script loads after page is interactive
|
||||
- Minimal performance impact
|
||||
@@ -95,7 +104,7 @@ The project is already configured in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||
```
|
||||
|
||||
@@ -104,7 +113,7 @@ environment:
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
@@ -188,7 +197,7 @@ In development, you'll see console logs:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -196,8 +205,9 @@ In development, you'll see console logs:
|
||||
### Analytics Not Working?
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify script is loading:**
|
||||
@@ -212,12 +222,12 @@ In development, you'll see console logs:
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Performance Tips
|
||||
|
||||
@@ -239,13 +249,13 @@ In development, you'll see console logs:
|
||||
1. ✅ **Setup complete** - All files are in place
|
||||
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
||||
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID`
|
||||
5. 🧪 **Test in development** - Verify events are tracked
|
||||
6. 🚀 **Deploy** - Analytics will work in production
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
||||
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
||||
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||
- [ ] Test in development mode (check console logs)
|
||||
|
||||
@@ -3,7 +3,8 @@ import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
import Lightbox from '../Lightbox';
|
||||
import dynamic from 'next/dynamic';
|
||||
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function GallerySection() {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useAnalytics } from '../analytics/useAnalytics';
|
||||
@@ -16,185 +15,79 @@ export default function Hero() {
|
||||
|
||||
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">
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<m.div
|
||||
className="max-w-5xl mx-auto md:mx-0"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<m.div variants={headingVariants}>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||
>
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<m.span
|
||||
className="relative z-10 text-accent italic"
|
||||
variants={accentVariants}
|
||||
>
|
||||
{chunks}
|
||||
</m.span>
|
||||
<m.div
|
||||
variants={scribbleVariants}
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||
>
|
||||
<Scribble variant="circle" />
|
||||
</m.div>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
</m.div>
|
||||
<m.div variants={subtitleVariants}>
|
||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||
variants={buttonContainerVariants}
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<div className="max-w-5xl mx-auto md:mx-0">
|
||||
<div className="animate-in fade-in slide-in-from-bottom-8 duration-700 ease-out fill-mode-both" style={{ animationDelay: '100ms' }}>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||
>
|
||||
<m.div variants={buttonVariants}>
|
||||
<Button
|
||||
href="/contact"
|
||||
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">→</span>
|
||||
</Button>
|
||||
</m.div>
|
||||
<m.div variants={buttonVariants}>
|
||||
<Button
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
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>
|
||||
</m.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
</Container>
|
||||
|
||||
<m.div
|
||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||
>
|
||||
<HeroIllustration />
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
|
||||
>
|
||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||
<m.div
|
||||
className="w-1 h-2 bg-white rounded-full"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic animate-in fade-in zoom-in-95 duration-700 ease-out fill-mode-both inline-block" style={{ animationDelay: '300ms' }}>
|
||||
{chunks}
|
||||
</span>
|
||||
<div className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '500ms' }}>
|
||||
<Scribble variant="circle" />
|
||||
</div>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 ease-out fill-mode-both" style={{ animationDelay: '400ms' }}>
|
||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6 animate-in fade-in slide-in-from-bottom-6 duration-700 ease-out fill-mode-both" style={{ animationDelay: '600ms' }}>
|
||||
<div>
|
||||
<Button
|
||||
href="/contact"
|
||||
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 hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('cta'),
|
||||
location: 'home_hero_primary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('cta')}
|
||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
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 hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('exploreProducts')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '100ms' }}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '2000ms' }}>
|
||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headingVariants = {
|
||||
hidden: { opacity: 1, y: 10, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const accentVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const scribbleVariants = {
|
||||
hidden: { opacity: 0, scale: 0, rotate: 180 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const subtitleVariants = {
|
||||
hidden: { opacity: 1, y: 20, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonContainerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
delayChildren: 0.4,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonVariants = {
|
||||
hidden: { opacity: 1, y: 30, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -125,8 +125,9 @@ export default function HeroIllustration() {
|
||||
}, []);
|
||||
|
||||
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
|
||||
const scale = isMobile ? 1.44 : 1;
|
||||
const opacity = isMobile ? 0.6 : 0.85;
|
||||
// Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance
|
||||
const scale = isMobile ? 1.6 : 1;
|
||||
const opacity = isMobile ? 0.9 : 0.85;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
|
||||
@@ -154,15 +155,15 @@ export default function HeroIllustration() {
|
||||
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1" result="blur" />
|
||||
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
@@ -215,10 +216,10 @@ export default function HeroIllustration() {
|
||||
</g>
|
||||
|
||||
{/* ANIMATED ENERGY FLOW */}
|
||||
<g>
|
||||
<g filter="url(#glow)">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
// Only animate a small subset of lines to reduce main-thread work significantly
|
||||
if (i % 5 !== 0) return null;
|
||||
// Only animate a subset of lines to reduce main-thread work
|
||||
if (i % 2 !== 0) return null;
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
|
||||
@@ -232,12 +233,16 @@ export default function HeroIllustration() {
|
||||
stroke="url(#energy-pulse)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
strokeDasharray: `${length * 0.2} ${length * 0.8}`,
|
||||
strokeDashoffset: length,
|
||||
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from={length}
|
||||
to={0}
|
||||
dur={`${1.5 + (i % 3) * 0.5}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
@@ -263,13 +268,14 @@ export default function HeroIllustration() {
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
r="3"
|
||||
fill="#82ed20"
|
||||
fillOpacity="0.3"
|
||||
filter="url(#soft-glow)"
|
||||
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }}
|
||||
/>
|
||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate
|
||||
attributeName="fillOpacity"
|
||||
values="0.2;0.5;0.2"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -289,26 +295,28 @@ export default function HeroIllustration() {
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<g transform="translate(0, -60)">
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: '0px 0px',
|
||||
animation: `spin-slow ${3 + i}s linear infinite`,
|
||||
}}
|
||||
>
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
key={`blade-${i}-${j}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
key={`blade-${i}-${j}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from={`${angle} 0 0`}
|
||||
to={`${angle + 360} 0 0`}
|
||||
dur={`${3 + i}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
@@ -24,7 +24,7 @@ export function PlaybackCursor() {
|
||||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<m.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import { Reorder, AnimatePresence, LazyMotion } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
@@ -146,7 +146,7 @@ export function RecordModeOverlay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
|
||||
@@ -10,7 +10,6 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
// Explicit non-magical detection
|
||||
const embedded =
|
||||
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||
@@ -21,13 +20,12 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||
url.searchParams.set('embedded', 'true');
|
||||
setIframeUrl(url.toString());
|
||||
}
|
||||
}, [isEmbedded]);
|
||||
|
||||
// Hydration Guard: Match server on first render
|
||||
if (!mounted) return <>{children}</>;
|
||||
}, []);
|
||||
|
||||
// Recursion Guard: If we are already in an embedded iframe,
|
||||
// strictly return just the children to prevent Inception.
|
||||
// Note: This causes a hydration mismatch remount ONLY when actually embedded (e.g. inside Directus).
|
||||
// Standard users and Lighthouse bots will NOT suffer a remount.
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import dynamic from 'next/dynamic';
|
||||
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
|
||||
import { Section, Container, Heading } from '@/components/ui';
|
||||
|
||||
export default function Gallery() {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"numberOfRuns": 1,
|
||||
"numberOfRuns": 3,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||
"onlyCategories": [
|
||||
"performance",
|
||||
"accessibility",
|
||||
"best-practices",
|
||||
"seo"
|
||||
],
|
||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||
}
|
||||
},
|
||||
@@ -49,4 +54,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ featuredImage: /uploads/2026/01/1767353529807.jpg
|
||||
locale: de
|
||||
category: Kabel Technologie
|
||||
excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.'
|
||||
public: false
|
||||
---
|
||||
# Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ featuredImage: /uploads/2026/01/1767353529807.jpg
|
||||
locale: en
|
||||
category: Cable Technology
|
||||
excerpt: 'KLZ Cables kicks off the new year with a strong addition: Johannes Gleich takes on the role of Senior Key Account Manager. Learn more about our new expert for infrastructure and energy suppliers.'
|
||||
public: false
|
||||
---
|
||||
# Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ services:
|
||||
# This fixes the Next.js URL-decoding bug on dynamic image proxy paths
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-le}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip"
|
||||
|
||||
@@ -39,7 +39,7 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-umami-website-id
|
||||
UMAMI_WEBSITE_ID=your-umami-website-id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
|
||||
23
lib/blog.ts
23
lib/blog.ts
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export interface PostFrontmatter {
|
||||
title: string;
|
||||
@@ -10,6 +11,7 @@ export interface PostFrontmatter {
|
||||
featuredImage?: string | null;
|
||||
category?: string;
|
||||
locale: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface PostMdx {
|
||||
@@ -18,6 +20,17 @@ export interface PostMdx {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
|
||||
// If explicitly marked as not public, hide in production
|
||||
if (post.frontmatter.public === false && config.isProduction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const postDate = new Date(post.frontmatter.date);
|
||||
const now = new Date();
|
||||
return !(postDate > now && config.isProduction);
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
@@ -31,11 +44,17 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
return {
|
||||
const postInfo = {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PostFrontmatter,
|
||||
content,
|
||||
};
|
||||
|
||||
if (!isPostVisible(postInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return postInfo;
|
||||
}
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
@@ -55,6 +74,7 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
content,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
@@ -78,6 +98,7 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
||||
frontmatter: data as PostFrontmatter,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.frontmatter.date as string).getTime() -
|
||||
|
||||
@@ -35,7 +35,7 @@ function createConfig() {
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
|
||||
enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ const booleanSchema = z.preprocess((val) => {
|
||||
const envExtension = {
|
||||
// Project specific overrides or additions
|
||||
AUTH_COOKIE_NAME: z.string().default('klz_gatekeeper_session'),
|
||||
TARGET: z.string().optional(),
|
||||
NEXT_PUBLIC_TARGET: z.string().optional(),
|
||||
|
||||
// Gatekeeper specifics not in base
|
||||
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
|
||||
@@ -31,7 +33,7 @@ const envExtension = {
|
||||
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||
|
||||
// Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_API_ENDPOINT: z.string().optional(),
|
||||
|
||||
// Mail Configuration
|
||||
|
||||
2
lib/framer-features.ts
Normal file
2
lib/framer-features.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { domAnimation } from 'framer-motion';
|
||||
export default domAnimation;
|
||||
@@ -28,6 +28,6 @@ export default function imgproxyLoader({
|
||||
return getImgproxyUrl(src, {
|
||||
width,
|
||||
resizing_type: 'fit',
|
||||
gravity: 'sm', // Use smart gravity (content-aware) instead of face detection (requires ML)
|
||||
gravity: 'sm', // Use smart gravity (content-aware) instead of face detection (requires ML/Pro)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface AnalyticsService {
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Type-safe event properties
|
||||
- Consistent API across implementations
|
||||
- Well-documented with JSDoc comments
|
||||
@@ -39,6 +40,7 @@ export interface AnalyticsService {
|
||||
Implements the `AnalyticsService` interface for Umami analytics.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Type-safe event tracking
|
||||
- Automatic pageview tracking
|
||||
- Browser environment detection
|
||||
@@ -46,6 +48,7 @@ Implements the `AnalyticsService` interface for Umami analytics.
|
||||
- Comprehensive JSDoc documentation
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { UmamiAnalyticsService } from '@/lib/services/analytics/umami-analytics-service';
|
||||
|
||||
@@ -59,12 +62,14 @@ service.trackPageview('/products/123');
|
||||
A no-op implementation used as a fallback when analytics are disabled.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Maintains the same API as other services
|
||||
- Safe to call even when analytics are disabled
|
||||
- No performance impact
|
||||
- Comprehensive JSDoc documentation
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { NoopAnalyticsService } from '@/lib/services/analytics/noop-analytics-service';
|
||||
|
||||
@@ -79,7 +84,7 @@ The service layer automatically selects the appropriate implementation based on
|
||||
|
||||
```typescript
|
||||
// In lib/services/create-services.ts
|
||||
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||
const umamiEnabled = Boolean(process.env.UMAMI_WEBSITE_ID);
|
||||
|
||||
const analytics = umamiEnabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
@@ -91,7 +96,7 @@ const analytics = umamiEnabled
|
||||
### Required for Umami
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
```
|
||||
|
||||
### Optional (defaults provided)
|
||||
@@ -109,10 +114,12 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
Track a custom event with optional properties.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `eventName` - The name of the event to track
|
||||
- `props` - Optional event properties (metadata)
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
service.track('product_add_to_cart', {
|
||||
product_id: '123',
|
||||
@@ -127,9 +134,11 @@ service.track('product_add_to_cart', {
|
||||
Track a pageview.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `url` - The URL to track (defaults to current location)
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// Track current page
|
||||
service.trackPageview();
|
||||
@@ -147,9 +156,11 @@ new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `enabled: boolean` - Whether analytics are enabled
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const service = new UmamiAnalyticsService({ enabled: true });
|
||||
```
|
||||
@@ -159,10 +170,11 @@ const service = new UmamiAnalyticsService({ enabled: true });
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new NoopAnalyticsService()
|
||||
new NoopAnalyticsService();
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const service = new NoopAnalyticsService();
|
||||
```
|
||||
@@ -172,13 +184,11 @@ const service = new NoopAnalyticsService();
|
||||
### AnalyticsEventProperties
|
||||
|
||||
```typescript
|
||||
type AnalyticsEventProperties = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
type AnalyticsEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const properties: AnalyticsEventProperties = {
|
||||
product_id: '123',
|
||||
@@ -253,7 +263,7 @@ services.analytics.track('button_click', {
|
||||
The service layer gracefully handles disabled analytics:
|
||||
|
||||
```typescript
|
||||
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
|
||||
// When UMAMI_WEBSITE_ID is not set:
|
||||
// - NoopAnalyticsService is used
|
||||
// - All calls are safe (no-op)
|
||||
// - No errors are thrown
|
||||
@@ -366,13 +376,13 @@ import { getAppServices } from '@/lib/services/create-services';
|
||||
|
||||
async function MyServerComponent() {
|
||||
const services = getAppServices();
|
||||
|
||||
|
||||
// Note: Analytics won't work in server components
|
||||
// Use client components for analytics tracking
|
||||
// But you can still access other services like cache
|
||||
|
||||
|
||||
const data = await services.cache.get('key');
|
||||
|
||||
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
@@ -382,14 +392,16 @@ async function MyServerComponent() {
|
||||
### Analytics Not Working
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify service selection:**
|
||||
|
||||
```typescript
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
|
||||
|
||||
const services = getAppServices();
|
||||
console.log(services.analytics); // Should be UmamiAnalyticsService
|
||||
```
|
||||
@@ -401,12 +413,12 @@ async function MyServerComponent() {
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify service is being used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify service is being used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Related Files
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { AnalyticsEventProperties, AnalyticsService } from './analytics-ser
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic fallback in create-services.ts
|
||||
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||
* const umamiEnabled = Boolean(process.env.UMAMI_WEBSITE_ID);
|
||||
* const analytics = umamiEnabled
|
||||
* ? new UmamiAnalyticsService({ enabled: true })
|
||||
* : new NoopAnalyticsService(); // Fallback when no website ID
|
||||
|
||||
@@ -55,7 +55,7 @@ let singleton: AppServices | undefined;
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic service selection based on environment
|
||||
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
|
||||
* // If UMAMI_WEBSITE_ID is set:
|
||||
* // services.analytics = UmamiAnalyticsService
|
||||
* // If not set:
|
||||
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||
|
||||
@@ -58,9 +58,10 @@ async function main() {
|
||||
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||
);
|
||||
// Try to pick a variety: home, some products, some blog posts
|
||||
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
|
||||
const others = urls.filter((u) => !home.includes(u));
|
||||
urls = [...home, ...others.slice(0, limit - home.length)];
|
||||
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
|
||||
const homeDE = urls.filter((u) => u.endsWith('/de'));
|
||||
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
|
||||
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
|
||||
}
|
||||
|
||||
console.log(`🧪 Pages to be tested:`);
|
||||
|
||||
89
tmp-lcp.json
Normal file
89
tmp-lcp.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"id": "largest-contentful-paint-element",
|
||||
"title": "Largest Contentful Paint element",
|
||||
"description": "This is the largest contentful element painted within the viewport. [Learn more about the Largest Contentful Paint element](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)",
|
||||
"score": 0,
|
||||
"scoreDisplayMode": "metricSavings",
|
||||
"displayValue": "5,490 ms",
|
||||
"metricSavings": {
|
||||
"LCP": 3000
|
||||
},
|
||||
"details": {
|
||||
"type": "list",
|
||||
"items": [
|
||||
{
|
||||
"type": "table",
|
||||
"headings": [
|
||||
{
|
||||
"key": "node",
|
||||
"valueType": "node",
|
||||
"label": "Element"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"node": {
|
||||
"type": "node",
|
||||
"lhId": "page-0-IMG",
|
||||
"path": "1,HTML,1,BODY,27,DIV,0,DIV,0,DIV,0,DIV,0,DIV,2,HEADER,0,DIV,0,DIV,0,A,0,IMG",
|
||||
"selector": "div.container > div.flex-shrink-0 > a > img.h-10",
|
||||
"boundingRect": {
|
||||
"top": 20,
|
||||
"bottom": 60,
|
||||
"left": 32,
|
||||
"right": 151,
|
||||
"width": 119,
|
||||
"height": 40
|
||||
},
|
||||
"snippet": "<img alt=\"Startseite\" width=\"120\" height=\"120\" decoding=\"async\" data-nimg=\"1\" class=\"h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110\" srcset=\"/logo-white.svg 1x, /logo-white.svg 2x\" src=\"/logo-white.svg\" style=\"color: transparent;\">",
|
||||
"nodeLabel": "Startseite"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"headings": [
|
||||
{
|
||||
"key": "phase",
|
||||
"valueType": "text",
|
||||
"label": "Phase"
|
||||
},
|
||||
{
|
||||
"key": "percent",
|
||||
"valueType": "text",
|
||||
"label": "% of LCP"
|
||||
},
|
||||
{
|
||||
"key": "timing",
|
||||
"valueType": "ms",
|
||||
"label": "Timing"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"phase": "TTFB",
|
||||
"timing": 459.198,
|
||||
"percent": "8%"
|
||||
},
|
||||
{
|
||||
"phase": "Load Delay",
|
||||
"timing": 58.11240179828974,
|
||||
"percent": "1%"
|
||||
},
|
||||
{
|
||||
"phase": "Load Time",
|
||||
"timing": 49.75227841406388,
|
||||
"percent": "1%"
|
||||
},
|
||||
{
|
||||
"phase": "Render Delay",
|
||||
"timing": 4920.729319787644,
|
||||
"percent": "90%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"guidanceLevel": 1
|
||||
}
|
||||
Reference in New Issue
Block a user