Compare commits

..

16 Commits

Author SHA1 Message Date
55a084e762 fix(blog): revert face detection imgproxy gravity causing 500 errors on standard open source image
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 55s
Build & Deploy / ⚡ Lighthouse (push) Successful in 7m18s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-21 14:09:35 +01:00
2b09cfc5d9 fix(ui): always show background on mobile navbar to prevent contrast issues
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 5m34s
Build & Deploy / 🏗️ Build (push) Successful in 7m54s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
2026-02-21 13:57:06 +01:00
927ce977f2 chore: release v1.2.6 with Next.js LCP, Hydration and Prod-Visibility fixes
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 42s
Build & Deploy / 🧪 QA (push) Successful in 5m17s
Build & Deploy / 🏗️ Build (push) Successful in 8m36s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Smoke Test (push) Successful in 53s
Build & Deploy / ⚡ Lighthouse (push) Successful in 7m38s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 13:26:14 +01:00
85bc03b9d2 fix(ci): pass correct UMAMI_WEBSITE_ID variable to docker build and env file
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 39s
Build & Deploy / 🧪 QA (push) Successful in 7m49s
Build & Deploy / 🏗️ Build (push) Successful in 8m24s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m47s
Build & Deploy / 🧪 Smoke Test (push) Successful in 3m16s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 11:12:06 +01:00
c4bc10ef76 fix: restore missing SVG animations in HeroIllustration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Failing after 17s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 01:56:09 +01:00
e95f7c6dd2 fix: restore missing translations on 404 page 2026-02-21 00:38:49 +01:00
17a91e48e6 perf: pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 8m19s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 54s
Build & Deploy / ⚡ Lighthouse (push) Successful in 8m38s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 23:42:33 +01:00
4d0a94d288 perf: pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 9m1s
Build & Deploy / 🏗️ Build (push) Successful in 12m31s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m50s
Build & Deploy / 🧪 Smoke Test (push) Successful in 3m34s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 21:24:12 +01:00
3568c13941 perf: pipeline 2026-02-20 19:06:19 +01:00
d538d7b9ec fix(blog): ensure target environment vars are parsed for accurate strict filtering in prod, and integrate face detection gravity for blog thumbnails
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-20 18:54:09 +01:00
8c08b552cf fix: svg stroke width 2026-02-20 18:48:10 +01:00
1dd74a3861 ci: fix pipeline cache corruption and secrets warning
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-20 16:20:53 +01:00
8d77ca45f7 feat(blog): implement scheduled and draft posts filtering and preview UI
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m59s
Build & Deploy / 🏗️ Build (push) Failing after 34s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 15:41:07 +01:00
c646815a3a chore(analytics): completely scrub NEXT_PUBLIC prefix from umami website id across codebase and docs
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m14s
Build & Deploy / 🧪 QA (push) Successful in 3m20s
Build & Deploy / 🧪 Smoke Test (push) Failing after 49s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m24s
Build & Deploy / 🏗️ Build (push) Successful in 3m2s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 15:29:50 +01:00
23bf327670 fix(analytics): relay umami events via secure nextjs proxy route handler
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Successful in 5m14s
Build & Deploy / 🧪 Smoke Test (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 4m21s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / ⚡ Lighthouse (push) Failing after 2m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 15:18:20 +01:00
c77f99ef37 feat(blog): johannes image
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m37s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-20 14:56:06 +01:00
40 changed files with 934 additions and 864 deletions

View File

@@ -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' }}"

View File

@@ -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
View File

@@ -11,4 +11,6 @@ lighthouserc.cjs
directus/uploads
!directus/extensions/
!directus/schema/
!directus/migrations/
!directus/migrations/
.next-docker

View File

@@ -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 && \

View File

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

View File

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

View File

@@ -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]) {

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerAppServices } from '@/lib/services/create-services.server';
import { config } from '@/lib/config';
/**
* Smart Proxy for Umami Analytics.
*
* This Route Handler receives tracking events from the browser,
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
* internal Umami API endpoint.
*
* This ensures:
* 1. The Website ID is NOT leaked to the client bundle.
* 2. The Umami API endpoint is hidden behind our domain.
* 3. We have full control over the tracking data.
*/
export async function POST(request: NextRequest) {
const services = getServerAppServices();
const logger = services.logger.child({ component: 'umami-smart-proxy' });
try {
const body = await request.json();
const { type, payload } = body;
// Inject the secret websiteId from server config
const websiteId = config.analytics.umami.websiteId;
if (!websiteId) {
logger.warn('Umami tracking received but no Website ID configured on server');
return NextResponse.json({ status: 'ignored' }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
const enhancedPayload = {
...payload,
website: websiteId,
};
const umamiEndpoint = config.analytics.umami.apiEndpoint;
// Log the event (internal only)
logger.debug('Forwarding analytics event', {
type,
url: payload.url,
website: websiteId.slice(0, 8) + '...',
});
const response = await fetch(`${umamiEndpoint}/api/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Umami API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: 'ok' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// Console error to ensure it appears in logs even if logger fails
console.error('CRITICAL PROXY ERROR:', {
message: errorMessage,
stack: errorStack,
endpoint: config.analytics.umami.apiEndpoint,
});
logger.error('Failed to proxy analytics request', {
error: errorMessage,
stack: errorStack,
});
return NextResponse.json(
{
error: 'Internal Server Error',
details: errorMessage, // Expose error for debugging
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
},
{ status: 500 },
);
}
}

39
chunk-analysis.json Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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">&rarr;</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">&rarr;</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;

View File

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

View File

@@ -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={{

View File

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

View File

@@ -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 (
<>

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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() -

View File

@@ -35,9 +35,9 @@ function createConfig() {
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
},
},

View File

@@ -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
View File

@@ -0,0 +1,2 @@
import { domAnimation } from 'framer-motion';
export default domAnimation;

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -348,10 +348,6 @@ const nextConfig = {
}
return [
{
source: '/stats/:path*',
destination: `${umamiUrl}/:path*`,
},
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

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