Compare commits

...

28 Commits

Author SHA1 Message Date
d9bddae20e refactor: enforce 'v' prefix for version tags in deploy workflow triggers and logic.
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🏗️ Build (push) Successful in 7m19s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Smoke Test (push) Failing after 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-17 21:29:53 +01:00
e7c482dabf chore(git): Add pre-push hook to enforce 'v' prefix on tags
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-17 21:25:57 +01:00
8974d89b33 fix(ci): Support semantic version tags without 'v' prefix
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-17 21:23:15 +01:00
f99ca4d35d fix(blog): Correct MDX syntax in billion-euro-package post
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m42s
Build & Deploy / 🏗️ Build (push) Successful in 4m3s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 20:19:22 +01:00
d10f15abe3 fix(infra): resolve gatekeeper label overwrite and alias collision
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 7m12s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 17:50:45 +01:00
9bdbcc2803 fix(orchestration): namespace Traefik labels with PROJECT_NAME to avoid collisions
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 7m8s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-17 17:06:16 +01:00
b08f07494c fix(orchestration): remove hardcoded external volume to fix pipeline failure
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m41s
Build & Deploy / 🏗️ Build (push) Successful in 2m52s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Failing after 45s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 16:53:57 +01:00
1f758758e3 fix: restore CMS connectivity and schema
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 7m8s
Build & Deploy / 🚀 Deploy (push) Failing after 19s
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Exposed Directus port 8055 for local health checks and scripting
- Added scripts to fix admin token and manually create missing collections
- Verified all service health checks are passing
2026-02-17 16:20:03 +01:00
fb8d9574b6 fix: resolve contact page 500 and Leaflet initialization errors
- Fixed Docker service names and volume configuration
- Bootstrapped Directus and applied schema
- Updated DIRECTUS_URL to local instance in .env
- Implemented manual Leaflet lifecycle management in LeafletMap.tsx
  to prevent re-initialization error
2026-02-17 16:13:31 +01:00
6856b7835c fix(deploy): enforce project name klz-cablescom for production to persist data volume
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 7m1s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 13:38:41 +01:00
1d074ba6d2 fix(infra): split PathPrefix into single-arg calls for Traefik v3
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Successful in 1m48s
Build & Deploy / 🏗️ Build (push) Successful in 8m0s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Traefik v3 only accepts one argument per PathPrefix. The multi-arg syntax
silently invalidated the entire public router, causing OG images, health,
sitemap and robots.txt to fall through to the auth-protected main router.
2026-02-17 02:09:54 +01:00
0e972983bc fix(infra): add TLS entrypoint/certresolver to deploy env generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
All Traefik routers were defaulting to entrypoints=web with tls=false,
making the app unreachable over HTTPS. Production worked because it had
these values set from a previous deploy, but testing never received them.
2026-02-17 02:06:34 +01:00
c979582193 fix(middleware): exclude static assets from matcher to prevent 404s on images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-17 02:00:06 +01:00
e47ba31763 fix(middleware): rename proxy.ts back to middleware.ts convention to fix OG image routing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m51s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 59s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 01:48:11 +01:00
28072908f7 fix(og-image): resolve 404s, migrate middleware to proxy.ts, and fix local port conflict
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m40s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 01:31:13 +01:00
7e6b4a3ed7 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 3m51s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 54s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 23:31:24 +01:00
d7e5a57344 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 3m58s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 23:18:41 +01:00
c859d5e677 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Smoke Test (push) Failing after 1m50s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-16 23:08:12 +01:00
e036dea089 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 3m49s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 53s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 22:35:39 +01:00
39088ca868 fix: build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 47s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 21:32:24 +01:00
18f9104623 fix: build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🏗️ Build (push) Successful in 6m58s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🧪 Smoke Test (push) Failing after 53s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-16 21:06:06 +01:00
76f745cc87 fix: resolve lint and build errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Failing after 1m0s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Added 'use client' to not-found.tsx
- Refactored RelatedProducts to Server Component to fix 'fs' import error
- Created RelatedProductLink for client-side analytics
- Fixed lint syntax issues in RecordModeVisuals.tsx
- Fixed rule-of-hooks violation in WebsiteVideo.tsx
2026-02-16 18:50:34 +01:00
848d58010f refactor(middleware): upgrade locale redirects from 307 to 308 for better scanner compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Failing after 54s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-16 18:45:33 +01:00
c0f5799667 feat(analytics): add blog engagement, ToC tracking, and 404 monitoring
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added BlogEngagementTracker for reading time and completion tracking
- Added ToC click tracking in blog posts
- Added global 404 error monitoring in not-found.tsx
- Completed 'Total Transparency' suite
2026-02-16 18:31:28 +01:00
0e089f9471 feat(analytics): implement total transparency suite and SEO metadata standardization
- Added global ScrollDepthTracker (25%, 50%, 75%, 100%)
- Implemented ProductEngagementTracker for deep product analytics
- Added field-level tracking to ContactForm and RequestQuoteForm
- Standardized SEO metadata (canonical, alternates, x-default) across all routes
- Created reusable TrackedLink and TrackedButton components for server components
- Fixed 'useAnalytics' hook error in Footer.tsx by adding 'use client'
2026-02-16 18:30:29 +01:00
52b17423dd feat(analytics): add umami data distribution refinement script and cleanup temporary data exports 2026-02-16 18:08:58 +01:00
bfd3c8164b fix(infra): resolve local directus service matching, improve branding script flexibility, and cleanup build artifacts 2026-02-16 18:07:56 +01:00
b091175b89 feat: conditionally enable recording studio and feedback tool via env vars
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m12s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-15 20:59:12 +01:00
66 changed files with 31706 additions and 1145 deletions

6
.env
View File

@@ -1,10 +1,12 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=true
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
@@ -15,7 +17,7 @@ MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# Directus
DIRECTUS_URL=https://cms.klz-cables.com
DIRECTUS_URL=http://klz-cms:8055
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me

View File

@@ -15,6 +15,7 @@ DIRECTUS_PORT=8055
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)

View File

@@ -100,7 +100,11 @@ jobs:
echo "traefik_rule=$TRAEFIK_RULE"
echo "next_public_url=https://$PRIMARY_HOST"
echo "directus_url=https://cms.$PRIMARY_HOST"
echo "project_name=$PRJ-$TARGET"
if [[ "$TARGET" == "production" ]]; then
echo "project_name=klz-cablescom"
else
echo "project_name=$PRJ-$TARGET"
fi
echo "short_sha=$SHORT_SHA"
} >> "$GITHUB_OUTPUT"
@@ -325,6 +329,9 @@ jobs:
echo "PROJECT_NAME=$PROJECT_NAME"
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
echo "TRAEFIK_ENTRYPOINT=websecure"
echo "TRAEFIK_TLS=true"
echo "TRAEFIK_CERT_RESOLVER=le"
echo "ENV_FILE=$ENV_FILE"
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"

32
.husky/pre-push Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
# Husky pre-push hook to validate tags
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
z40=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha
do
# Check if we are pushing a tag
case "$local_ref" in
refs/tags/*)
tag_name="${local_ref#refs/tags/}"
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
echo ""
echo "❌ ERROR: Invalid tag name '$tag_name'"
echo "--------------------------------------------------"
echo "Consistency check failed: All tags MUST start with 'v'."
echo "Example: v1.0.10"
echo ""
echo "Please delete the invalid tag and create a new one:"
echo " git tag -d $tag_name"
echo " git tag v$tag_name"
echo "--------------------------------------------------"
echo ""
exit 1
fi
;;
esac
done
exit 0

View File

@@ -1,5 +1,5 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS builder
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
WORKDIR /app
# Arguments for build-time configuration
@@ -35,12 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
COPY . .
# Stage 2: Development (Hot-Reloading)
FROM builder AS development
FROM base AS development
ENV NODE_ENV=development
CMD ["pnpm", "dev:local"]
# Build application
# RUN pnpm build
# Stage 3: Builder (Production)
FROM base AS builder
RUN pnpm build
# Stage 3: Runner
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner

View File

@@ -3,6 +3,8 @@ import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({

View File

@@ -6,6 +6,7 @@ import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
interface PageProps {
params: Promise<{
@@ -38,11 +39,11 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '',
alternates: {
canonical: `/${locale}/${slug}`,
canonical: `${SITE_URL}/${locale}/${slug}`,
languages: {
de: `/de/${slug}`,
en: `/en/${slug}`,
'x-default': `/en/${slug}`,
de: `${SITE_URL}/de/${slug}`,
en: `${SITE_URL}/en/${slug}`,
'x-default': `${SITE_URL}/en/${slug}`,
},
},
openGraph: {
@@ -110,15 +111,19 @@ export default async function StandardPage({ params }: PageProps) {
<div className="relative z-10 max-w-2xl">
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
<a
<TrackedLink
href={`/${locale}/contact`}
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
eventProperties={{
location: 'generic_page_support_cta',
page_slug: slug,
}}
>
{t('contactUs')}
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
&rarr;
</span>
</a>
</TrackedLink>
</div>
</div>
</div>

View File

@@ -4,6 +4,8 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({

View File

@@ -11,6 +11,7 @@ import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
interface BlogPostProps {
params: Promise<{
@@ -30,11 +31,11 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
title: post.frontmatter.title,
description: description,
alternates: {
canonical: `/${locale}/blog/${slug}`,
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
languages: {
de: `/de/blog/${slug}`,
en: `/en/blog/${slug}`,
'x-default': `/en/blog/${slug}`,
de: `${SITE_URL}/de/blog/${slug}`,
en: `${SITE_URL}/en/blog/${slug}`,
'x-default': `${SITE_URL}/en/blog/${slug}`,
},
},
openGraph: {
@@ -67,6 +68,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
<BlogEngagementTracker
title={post.frontmatter.title}
slug={slug}
category={post.frontmatter.category}
readingTime={getReadingTime(post.content)}
/>
{/* Featured Image Header */}
{post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -4,7 +4,7 @@ import { getAllPosts } from '@/lib/blog';
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
import Reveal from '@/components/Reveal';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
interface BlogIndexProps {
@@ -20,11 +20,11 @@ export async function generateMetadata({ params }: BlogIndexProps) {
title: t('title'),
description: t('description'),
alternates: {
canonical: `/${locale}/blog`,
canonical: `${SITE_URL}/${locale}/blog`,
languages: {
de: '/de/blog',
en: '/en/blog',
'x-default': '/en/blog',
de: `${SITE_URL}/de/blog`,
en: `${SITE_URL}/en/blog`,
'x-default': `${SITE_URL}/en/blog`,
},
},
openGraph: {
@@ -42,6 +42,7 @@ export async function generateMetadata({ params }: BlogIndexProps) {
export default async function BlogIndex({ params }: BlogIndexProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -26,8 +26,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
alternates: {
canonical: `${SITE_URL}/${locale}/contact`,
languages: {
'de-DE': '/de/contact',
'en-US': '/en/contact',
de: `${SITE_URL}/de/contact`,
en: `${SITE_URL}/en/contact`,
'x-default': `${SITE_URL}/en/contact`,
},
},
openGraph: {

View File

@@ -2,6 +2,7 @@ import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
@@ -42,16 +43,13 @@ export const viewport: Viewport = {
themeColor: '#001a4d',
};
export default async function LocaleLayout({
children,
params,
}: {
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure locale is a valid string, fallback to 'en'
const params = await props.params;
const { locale } = params;
const { children } = props;
const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
@@ -66,12 +64,9 @@ export default async function LocaleLayout({
messages = {};
}
// Track pageview on the server with high-fidelity header context
const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices();
// We wrap this in a try-catch to allow static rendering during build
// headers() and cookies() force dynamic rendering in Next.js
try {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
@@ -85,10 +80,8 @@ export default async function LocaleLayout({
});
}
// Track initial server-side pageview
serverServices.analytics.trackPageview();
} catch {
// Falls back to noop or client-side only during static generation
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn(
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
@@ -96,38 +89,16 @@ export default async function LocaleLayout({
}
}
// Read directly from process.env — bypasses all abstraction to guarantee correctness
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head>
<style dangerouslySetInnerHTML={{
__html: `
/* Effectively Invisible Scrollbar */
::-webkit-scrollbar {
width: 2px;
height: 2px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(130, 237, 32, 0.4);
}
* {
scrollbar-width: none;
}
*:hover {
scrollbar-width: thin;
scrollbar-color: rgba(130, 237, 32, 0.2) transparent;
}
`}} />
</head>
<head></head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider>
<RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals>
<JsonLd />
<Header />
@@ -139,8 +110,9 @@ export default async function LocaleLayout({
<Suspense fallback={null}>
<AnalyticsProvider />
<ScrollDepthTracker />
</Suspense>
<ToolCoordinator />
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
</NextIntlClientProvider>
</body>

View File

@@ -1,9 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble';
import { useEffect } from 'react';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default function NotFound() {
const t = useTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
});
}, [trackEvent]);
return (
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
@@ -16,19 +28,17 @@ export default function NotFound() {
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">
{t('description')}
</p>
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant="accent" size="lg">

View File

@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
console.log('🖼️ OG Image Handler Called');
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();

View File

@@ -85,11 +85,11 @@ export async function generateMetadata({
title,
description,
alternates: {
canonical: `/${locale}`,
canonical: `${SITE_URL}/${locale}`,
languages: {
de: '/de',
en: '/en',
'x-default': '/en',
de: `${SITE_URL}/de`,
en: `${SITE_URL}/en`,
'x-default': `${SITE_URL}/en`,
},
},
openGraph: {

View File

@@ -16,6 +16,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
interface ProductPageProps {
params: Promise<{
@@ -52,11 +53,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle,
description: categoryDesc,
alternates: {
canonical: `/${locale}/products/${productSlug}`,
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -80,11 +81,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: product.frontmatter.title,
description: product.frontmatter.description,
alternates: {
canonical: `/${locale}/products/${slug.join('/')}`,
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -212,7 +213,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
<Link
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-3 opacity-30">/</span>
@@ -353,6 +357,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
return (
<div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */}
<ProductEngagementTracker
productName={product.frontmatter.title}
productSlug={productSlug}
categories={product.frontmatter.categories}
sku={product.frontmatter.sku}
/>
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
{/* Background Decorative Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
@@ -361,7 +371,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
<Link
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-4 opacity-20">/</span>

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -4,9 +4,9 @@ import { Badge, Button, Card, Container, Heading, Section } from '@/components/u
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
interface ProductsPageProps {
params: Promise<{
@@ -23,11 +23,11 @@ export async function generateMetadata({ params }: ProductsPageProps): Promise<M
title,
description,
alternates: {
canonical: `/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
languages: {
de: `/de/${await mapFileSlugToTranslated('products', 'de')}`,
en: `/en/${await mapFileSlugToTranslated('products', 'en')}`,
'x-default': `/en/${await mapFileSlugToTranslated('products', 'en')}`,
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
},
},
openGraph: {
@@ -135,7 +135,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
{categories.map((category, idx) => (
<Reveal key={idx} delay={idx * 100}>
<Link key={idx} href={category.href} className="group block">
<TrackedLink
key={idx}
href={category.href}
className="group block"
eventProperties={{
category_title: category.title,
location: 'products_index',
}}
>
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
<Image
@@ -195,7 +203,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</div>
</div>
</Card>
</Link>
</TrackedLink>
</Reveal>
))}
</div>

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -6,6 +6,7 @@ import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
import TrackedButton from '@/components/analytics/TrackedButton';
interface TeamPageProps {
params: Promise<{
@@ -22,11 +23,11 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
title,
description,
alternates: {
canonical: `/${locale}/team`,
canonical: `${SITE_URL}/${locale}/team`,
languages: {
de: '/de/team',
en: '/en/team',
'x-default': '/en/team',
de: `${SITE_URL}/de/team`,
en: `${SITE_URL}/en/team`,
'x-default': `${SITE_URL}/en/team`,
},
},
openGraph: {
@@ -133,15 +134,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
{t('michael.description')}
</p>
<Button
<TrackedButton
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Michael Bodemer',
location: 'team_page',
}}
>
{t('michael.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</TrackedButton>
</div>
</Reveal>
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
@@ -241,15 +247,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
{t('klaus.description')}
</p>
<Button
<TrackedButton
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="saturated"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Klaus Mintel',
location: 'team_page',
}}
>
{t('klaus.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</TrackedButton>
</div>
</Reveal>
</div>

View File

@@ -1,85 +0,0 @@
> klz-cables-nextjs@1.0.0 build /Users/marcmintel/Projects/klz-2026
> next build
▲ Next.js 16.1.6 (Turbopack)
- Environments: .env.production, .env
- Experiments (use with caution):
· clientTraceMetadata
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
Creating an optimized production build ...
✓ Compiled successfully in 5.2s
Running next.config.js provided runAfterProductionCompile ...
✓ Completed runAfterProductionCompile in 329ms
Running TypeScript ...
Collecting page data using 15 workers ...
Generating static pages using 15 workers (0/21) ...
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop notification service initialized (notifications disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
Generating static pages using 15 workers (5/21)
Generating static pages using 15 workers (10/21)
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Notification service initialized (noop)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
Generating static pages using 15 workers (15/21)
✓ Generating static pages using 15 workers (21/21) in 512.4ms
Finalizing page optimization ...
Route (app)
┌ ○ /_not-found
├ ƒ /[locale]
├ ƒ /[locale]/[slug]
├ ƒ /[locale]/[slug]/opengraph-image
├ ƒ /[locale]/api/og/product
├ ƒ /[locale]/blog
├ ƒ /[locale]/blog/[slug]
├ ƒ /[locale]/blog/[slug]/opengraph-image
├ ƒ /[locale]/blog/opengraph-image
├ ƒ /[locale]/contact
├ ƒ /[locale]/contact/opengraph-image
├ ƒ /[locale]/opengraph-image
├ ƒ /[locale]/products
├ ƒ /[locale]/products/[...slug]
├ ƒ /[locale]/products/opengraph-image
├ ƒ /[locale]/team
├ ƒ /[locale]/team/opengraph-image
├ ƒ /api/feedback
├ ƒ /api/health/cms
├ ƒ /api/whoami
├ ƒ /errors/api/relay
├ ƒ /health
├ ○ /manifest.webmanifest
├ ○ /robots.txt
├ ƒ /sitemap.xml
└ ƒ /stats/api/send
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand

View File

@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default function ContactForm() {
const t = useTranslations('Contact');
const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false);
const handleFocus = (fieldId: string) => {
// Initial form start
if (!hasStarted) {
setHasStarted(true);
trackEvent(AnalyticsEvents.FORM_START, {
form_id: 'contact_form',
form_name: 'Contact',
});
}
// Field-level transparency
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
form_id: 'contact_form',
field_id: fieldId,
});
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -29,10 +48,18 @@ export default function ContactForm() {
(e.target as HTMLFormElement).reset();
} else {
console.error('Contact form submission failed:', { email, error: result.error });
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'contact_form',
error: result.error || 'submission_failed',
});
setStatus('error');
}
} catch (error) {
console.error('Contact form submission error:', { email, error });
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'contact_form',
error: (error as Error).message || 'unexpected_error',
});
setStatus('error');
}
}
@@ -112,7 +139,7 @@ export default function ContactForm() {
name="name"
autoComplete="name"
enterKeyHint="next"
placeholder={t('form.namePlaceholder')}
onFocus={() => handleFocus('name')}
required
/>
</div>
@@ -126,6 +153,7 @@ export default function ContactForm() {
inputMode="email"
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('email')}
required
/>
</div>
@@ -137,6 +165,7 @@ export default function ContactForm() {
rows={4}
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('message')}
required
/>
</div>

View File

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

View File

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

View File

@@ -8,10 +8,13 @@ import { usePathname } from 'next/navigation';
import { Button } from './ui';
import { useEffect, useState } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
export default function Header() {
const t = useTranslations('Navigation');
const pathname = usePathname();
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -46,22 +49,10 @@ export default function Header() {
return segments.join('/');
};
const [productsSlug, setProductsSlug] = useState('products');
useEffect(() => {
// We can't use mapFileSlugToTranslated directly in client components easily without an API or similar
// For now, let's just check the locale
if (currentLocale === 'de') {
setProductsSlug('produkte');
} else {
setProductsSlug('products');
}
}, [currentLocale]);
const menuItems = [
{ label: t('home'), href: '/' },
{ label: t('team'), href: '/team' },
{ label: t('products'), href: `/${productsSlug}` },
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
{ label: t('blog'), href: '/blog' },
];
@@ -91,7 +82,15 @@ export default function Header() {
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
>
<Link href={`/${currentLocale}`}>
<Link
href={`/${currentLocale}`}
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'header',
})
}
>
<Image
src={logoSrc}
alt={t('home')}
@@ -121,7 +120,14 @@ export default function Header() {
<motion.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => setIsMobileMenuOpen(false)}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
@@ -151,6 +157,14 @@ export default function Header() {
>
<Link
href={getPathForLocale('en')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'en',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
@@ -169,6 +183,14 @@ export default function Header() {
>
<Link
href={getPathForLocale('de')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'de',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
@@ -186,6 +208,12 @@ export default function Header() {
variant="white"
size="md"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
>
{t('contact')}
</Button>
@@ -208,7 +236,14 @@ export default function Header() {
damping: 20,
delay: 0.5,
}}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<motion.svg
className="w-7 h-7"
@@ -286,7 +321,14 @@ export default function Header() {
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => setIsMobileMenuOpen(false)}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
>
{item.label}

View File

@@ -1,18 +1,18 @@
'use client';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import React, { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix for default marker icon in Leaflet with Next.js
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
if (typeof window !== 'undefined') {
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
L.Marker.prototype.options.icon = DefaultIcon;
}
interface LeafletMapProps {
address: string;
@@ -21,25 +21,46 @@ interface LeafletMapProps {
}
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
const position: [number, number] = [lat, lng];
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
return (
<MapContainer
center={position}
zoom={15}
scrollWheelZoom={false}
className="h-full w-full z-0"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
<div className="text-primary font-bold">KLZ Cables</div>
<div className="text-sm whitespace-pre-line">{address}</div>
</Popup>
</Marker>
</MapContainer>
);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Initialize map
const map = L.map(mapRef.current, {
center: [lat, lng],
zoom: 15,
scrollWheelZoom: false,
});
// Add tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
// Add marker
const marker = L.marker([lat, lng]).addTo(map);
// Create popup content
const popupContent = `
<div class="text-primary font-bold">KLZ Cables</div>
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
`;
marker.bindPopup(popupContent);
mapInstanceRef.current = map;
// Cleanup on unmount
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [lat, lng, address]);
return <div ref={mapRef} className="h-full w-full z-0" />;
}

View File

@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface RelatedProductLinkProps {
href: string;
productSlug: string;
productTitle: string;
children: React.ReactNode;
className?: string;
}
export function RelatedProductLink({
href,
productSlug,
productTitle,
children,
className,
}: RelatedProductLinkProps) {
const { trackEvent } = useAnalytics();
return (
<Link
href={href}
className={className}
onClick={() =>
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: productSlug,
product_name: productTitle,
location: 'related_products',
})
}
>
{children}
</Link>
);
}

View File

@@ -1,8 +1,7 @@
import { getAllProducts } from '@/lib/mdx';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import Link from 'next/link';
import { RelatedProductLink } from './RelatedProductLink';
interface RelatedProductsProps {
currentSlug: string;
@@ -10,15 +9,19 @@ interface RelatedProductsProps {
locale: string;
}
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
const allProducts = await getAllProducts(locale);
export default async function RelatedProducts({
currentSlug,
categories,
locale,
}: RelatedProductsProps) {
const products = await getAllProducts(locale);
const t = await getTranslations('Products');
// Filter products: same category, not current product
const related = allProducts
.filter(p =>
p.slug !== currentSlug &&
p.frontmatter.categories.some(cat => categories.includes(cat))
const related = products
.filter(
(p) =>
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
)
.slice(0, 3); // Limit to 3 for better spacing
@@ -36,25 +39,31 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{related.map(async (product) => {
{related.map((product) => {
// Find the category slug for the link
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
const catSlug = categorySlugs.find(slug => {
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
);
}) || 'low-voltage-cables';
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
const translatedCategorySlug = await mapFileSlugToTranslated(catSlug, locale);
const productsBase = await mapFileSlugToTranslated('products', locale);
const categorySlugs = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const catSlug =
categorySlugs.find((slug) => {
const key = slug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
);
}) || 'low-voltage-cables';
return (
<Link
<RelatedProductLink
key={product.slug}
href={`/${locale}/${productsBase}/${translatedCategorySlug}/${translatedProductSlug}`}
href={`/${locale}/products/${catSlug}/${product.slug}`}
productSlug={product.slug}
productTitle={product.frontmatter.title}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
@@ -76,8 +85,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="p-8">
<div className="flex flex-wrap gap-2 mb-3">
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
<span
key={idx}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
@@ -89,12 +101,22 @@ export default async function RelatedProducts({ currentSlug, categories, locale
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
{t('details')}
</span>
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
<svg
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</Link>
</RelatedProductLink>
);
})}
</div>

View File

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
interface RequestQuoteFormProps {
productName: string;
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [email, setEmail] = useState('');
const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false);
const handleFocus = (fieldId: string) => {
// Initial form start
if (!hasStarted) {
setHasStarted(true);
trackEvent(AnalyticsEvents.FORM_START, {
form_id: 'quote_request_form',
form_name: 'Product Quote Inquiry',
product_name: productName,
});
}
// Field-level transparency
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
form_id: 'quote_request_form',
field_id: fieldId,
product_name: productName,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,10 +60,20 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
setEmail('');
setRequest('');
} else {
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'quote_request_form',
product_name: productName,
error: result.error || 'submission_failed',
});
setStatus('error');
}
} catch (error) {
console.error('Form submission error:', error);
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'quote_request_form',
product_name: productName,
error: (error as Error).message || 'unexpected_error',
});
setStatus('error');
}
};
@@ -131,6 +162,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
required
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('email')}
placeholder={t('email')}
className="h-9 text-xs !mt-0"
/>
@@ -143,6 +175,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
rows={3}
value={request}
onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('request')}
placeholder={t('message')}
className="text-xs !mt-0"
/>

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface BlogEngagementTrackerProps {
title: string;
slug: string;
category?: string;
readingTime: number;
}
/**
* BlogEngagementTracker
* Tracks reading time and article completion.
*/
export default function BlogEngagementTracker({
title,
slug,
category,
readingTime,
}: BlogEngagementTrackerProps) {
const { trackEvent } = useAnalytics();
useEffect(() => {
// Article start
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
title,
slug,
category,
estimated_reading_time: readingTime,
location: 'blog_post_pdp',
});
const startTime = Date.now();
return () => {
const dwellTime = Math.round((Date.now() - startTime) / 1000);
// We only consider it a "read" if they stay a reasonable amount of time
// or if they scroll (covered by ScrollDepthTracker)
trackEvent('blog_dwell_time', {
title,
slug,
seconds: dwellTime,
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
});
};
}, [title, slug, category, readingTime, trackEvent]);
return null;
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface ProductEngagementTrackerProps {
productName: string;
productSlug: string;
categories: string[];
sku?: string;
}
/**
* ProductEngagementTracker
* Deep analytics for product pages.
* Tracks specific view events with full metadata for sales analysis.
*/
export default function ProductEngagementTracker({
productName,
productSlug,
categories,
sku,
}: ProductEngagementTrackerProps) {
const { trackEvent } = useAnalytics();
useEffect(() => {
// Standardized product view event for "High-Fidelity" sales insights
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: productSlug,
product_name: productName,
product_sku: sku,
product_categories: categories.join(', '),
location: 'pdp_standard',
});
// We can also track "Engagement Start" to measure dwell time later
const startTime = Date.now();
return () => {
const dwellTime = Math.round((Date.now() - startTime) / 1000);
trackEvent('pdp_dwell_time', {
product_id: productSlug,
seconds: dwellTime,
});
};
}, [productName, productSlug, categories, sku, trackEvent]);
return null;
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
/**
* ScrollDepthTracker
* Tracks user scroll progress across pages.
* Fires events at 25%, 50%, 75%, and 100% depth.
*/
export default function ScrollDepthTracker() {
const pathname = usePathname();
const { trackEvent } = useAnalytics();
const trackedDepths = useRef<Set<number>>(new Set());
// Reset tracking when path changes
useEffect(() => {
trackedDepths.current.clear();
}, [pathname]);
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Calculate how far the user has scrolled in percentage
// documentHeight - windowHeight is the total scrollable distance
const totalScrollable = documentHeight - windowHeight;
if (totalScrollable <= 0) return; // Not scrollable
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
// We only care about specific milestones
const milestones = [25, 50, 75, 100];
milestones.forEach((milestone) => {
if (scrollPercentage >= milestone && !trackedDepths.current.has(milestone)) {
trackedDepths.current.add(milestone);
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
depth: milestone,
path: pathname,
});
}
});
};
// Use passive listener for better performance
window.addEventListener('scroll', handleScroll, { passive: true });
// Initial check (in case page is short or already scrolled)
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [pathname, trackEvent]);
return null;
}

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { Button, ButtonProps } from '../ui/Button';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface TrackedButtonProps extends ButtonProps {
eventName?: string;
eventProperties?: Record<string, any>;
}
/**
* A wrapper around the project's Button component that tracks click events.
* Safe to use in server components.
*/
export default function TrackedButton({
eventName = AnalyticsEvents.BUTTON_CLICK,
eventProperties = {},
onClick,
...props
}: TrackedButtonProps) {
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
trackEvent(eventName, {
...eventProperties,
label: typeof props.children === 'string' ? props.children : eventProperties.label,
});
if (onClick) onClick(e);
};
return <Button {...props} onClick={handleClick} />;
}

View File

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

View File

@@ -1,18 +1,18 @@
/**
* Analytics Events Utility
*
*
* Centralized definitions for common analytics events and their properties.
* This helps maintain consistency across the application and makes it easier
* to track meaningful events.
*
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
*
*
* function ProductPage() {
* const { trackEvent } = useAnalytics();
*
*
* const handleAddToCart = (productId: string, productName: string) => {
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
* product_id: productId,
@@ -20,7 +20,7 @@
* page: 'product-detail'
* });
* };
*
*
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
* }
* ```
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
PAGE_VIEW: 'pageview',
PAGE_SCROLL: 'page_scroll',
PAGE_EXIT: 'page_exit',
SCROLL_DEPTH: 'scroll_depth',
// User Interaction Events
BUTTON_CLICK: 'button_click',
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
FORM_SUBMIT: 'form_submit',
FORM_START: 'form_start',
FORM_ERROR: 'form_error',
FORM_FIELD_FOCUS: 'form_field_focus',
// E-commerce Events
PRODUCT_VIEW: 'product_view',
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
PRODUCT_PURCHASE: 'product_purchase',
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
PRODUCT_TAB_SWITCH: 'product_tab_switch',
// Search & Filter Events
SEARCH: 'search',
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
TOGGLE_SWITCH: 'toggle_switch',
ACCORDION_TOGGLE: 'accordion_toggle',
TAB_SWITCH: 'tab_switch',
TOC_CLICK: 'toc_click',
// Error & Performance Events
ERROR: 'error',

View File

@@ -2,6 +2,8 @@
import React, { useEffect, useState } from 'react';
import { cn } from '@/components/ui/utils';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
interface TocItem {
id: string;
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('');
const { trackEvent } = useAnalytics();
useEffect(() => {
const observerOptions = {
rootMargin: '-10% 0% -70% 0%',
threshold: 0
threshold: 0,
};
const observer = new IntersectionObserver((entries) => {
@@ -66,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
<a
href={`#${heading.id}`}
className={cn(
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
'text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug',
activeId === heading.id
? "text-primary font-bold translate-x-1"
: "text-text-secondary font-medium hover:translate-x-1"
? 'text-primary font-bold translate-x-1'
: 'text-text-secondary font-medium hover:translate-x-1',
)}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
trackEvent(AnalyticsEvents.TOC_CLICK, {
heading_id: heading.id,
heading_text: heading.text,
location: 'blog_sidebar',
});
const yOffset = -100;
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });

View File

@@ -5,11 +5,14 @@ import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero() {
const t = useTranslations('Home.hero');
const locale = useLocale();
const { trackEvent } = useAnalytics();
return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
@@ -60,6 +63,12 @@ export default function Hero() {
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('cta'),
location: 'home_hero_primary',
})
}
>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
@@ -71,6 +80,12 @@ export default function Hero() {
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{t('exploreProducts')}
</Button>

View File

@@ -26,6 +26,7 @@ interface RecordModeContextType {
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
isEnabled: boolean;
}
const RecordModeContext = createContext<RecordModeContextType | null>(null);
@@ -56,12 +57,19 @@ export function useRecordMode(): RecordModeContextType {
setHoveredEventId: () => {},
setEvents: () => {},
isClicking: false,
isEnabled: false,
};
}
return context;
}
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
export function RecordModeProvider({
children,
isEnabled = false,
}: {
children: React.ReactNode;
isEnabled?: boolean;
}) {
const [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState<RecordEvent[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
@@ -74,45 +82,54 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => {
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
}, [isEnabled]);
useEffect(() => {
if (!isEnabled) return;
const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top);
setIsEmbedded(embedded);
}, []);
}, [isEnabled]);
const setIsActive = (active: boolean) => {
if (!isEnabled) return;
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
if (active) setIsActiveState(false);
if (active && isEnabled) setIsActiveState(false);
};
const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
if (!isEnabled) return;
const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true;
}, []);
}, [isEnabled]);
useEffect(() => {
if (!isLoadedRef.current) return;
if (!isEnabled || !isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events]);
}, [events, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
}, [isActive]);
}, [isActive, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
if (isEmbedded) {
const handlePlaybackMessage = (e: MessageEvent) => {
if (e.data.type === 'PLAY_EVENT') {
@@ -177,10 +194,10 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
window.addEventListener('message', handlePlaybackMessage);
return () => window.removeEventListener('message', handlePlaybackMessage);
}
}, [isEmbedded]);
}, [isEmbedded, isEnabled]);
useEffect(() => {
if (isEmbedded || !isActive) return;
if (!isEnabled || isEmbedded || !isActive) return;
const event = events.find((e) => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) {
@@ -189,9 +206,10 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
'*',
);
}
}, [hoveredEventId, events, isActive, isEmbedded]);
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
if (!isEnabled) return;
const newEvent: RecordEvent = {
realClick: false,
...event,
@@ -202,12 +220,14 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
};
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
if (!isEnabled) return;
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
);
};
const reorderEvents = (startIndex: number, endIndex: number) => {
if (!isEnabled) return;
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
@@ -215,10 +235,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
};
const removeEvent = (id: string) => {
if (!isEnabled) return;
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
if (!isEnabled) return;
if (confirm('Clear all recorded events?')) setEvents([]);
};
@@ -233,11 +255,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
: null;
const saveSession = (name: string) => {
if (!isEnabled) return;
console.log('Saving session:', name, events);
};
const playEvents = async () => {
if (events.length === 0 || isPlayingRef.current) return;
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true);
isPlayingRef.current = true;
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
@@ -263,7 +286,6 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
} else {
// Self-execution logic for guest
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -361,6 +383,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
hoveredEventId,
setHoveredEventId,
isClicking,
isEnabled,
}}
>
{children}

View File

@@ -4,34 +4,36 @@ import React from 'react';
import { useRecordMode } from './RecordModeContext';
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
const [mounted, setMounted] = React.useState(false);
const [isEmbedded, setIsEmbedded] = React.useState(false);
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
const [mounted, setMounted] = React.useState(false);
const [isEmbedded, setIsEmbedded] = React.useState(false);
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';
setIsEmbedded(embedded);
React.useEffect(() => {
setMounted(true);
// Explicit non-magical detection
const embedded =
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
setIsEmbedded(embedded);
if (!embedded) {
const url = new URL(window.location.href);
url.searchParams.set('embedded', 'true');
setIframeUrl(url.toString());
}
}, [isEmbedded]);
if (!embedded) {
const url = new URL(window.location.href);
url.searchParams.set('embedded', 'true');
setIframeUrl(url.toString());
}
}, [isEmbedded]);
// Hydration Guard: Match server on first render
if (!mounted) return <>{children}</>;
// 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.
if (isEmbedded) {
return (
<>
<style dangerouslySetInnerHTML={{
__html: `
// Recursion Guard: If we are already in an embedded iframe,
// strictly return just the children to prevent Inception.
if (isEmbedded) {
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
#nextjs-portal,
#nextjs-portal-root,
@@ -49,7 +51,7 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
[style*="z-index: 9999"],
[style*="z-index: 10000"],
.fixed.bottom-6.left-6,
.fixed.bottom-6.left-1\/2,
.fixed.bottom-6.left-1/2,
.feedback-ui-overlay,
[id^="feedback-"],
[class^="feedback-"] {
@@ -78,18 +80,21 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
overflow-x: hidden !important;
overflow-y: auto !important;
}
`}} />
{children}
</>
);
}
`,
}}
/>
{children}
</>
);
}
return (
<>
{/* Global Style for Body Lock */}
{isActive && (
<style dangerouslySetInnerHTML={{
__html: `
return (
<>
{/* Global Style for Body Lock */}
{isActive && (
<style
dangerouslySetInnerHTML={{
__html: `
html, body {
overflow: hidden !important;
height: 100vh !important;
@@ -102,78 +107,143 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
.nextjs-static-indicator {
display: none !important;
}
`}} />
`,
}}
/>
)}
<div
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
>
{/* Studio Background - Only visible when active */}
{isActive && (
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
<div
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
style={{
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
filter: 'blur(160px)',
animation: 'mesh-float-1 18s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
style={{
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
filter: 'blur(150px)',
animation: 'mesh-float-2 22s ease-in-out infinite',
}}
/>
<div
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
style={{
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
filter: 'blur(130px)',
animation: 'mesh-float-3 14s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
style={{
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
filter: 'blur(140px)',
animation: 'mesh-float-4 20s ease-in-out infinite',
}}
/>
<div
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundSize: '128px 128px',
}}
/>
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
}}
/>
</div>
)}
<div
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
style={{
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
filter: isBlurry ? 'blur(4px)' : 'none',
willChange: 'transform, filter',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden',
}}
>
<div
className={
isActive
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
: 'w-full h-full'
}
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
>
{isActive && (
<>
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
<div
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
style={{
background:
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
animation: 'pulse-ring 4s ease-in-out infinite',
}}
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
</>
)}
<div className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}>
{/* Studio Background - Only visible when active */}
{isActive && (
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
<div className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
style={{ background: 'radial-gradient(circle, #10b981 0%, transparent 70%)', filter: 'blur(160px)', animation: 'mesh-float-1 18s ease-in-out infinite' }} />
<div className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
style={{ background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)', filter: 'blur(150px)', animation: 'mesh-float-2 22s ease-in-out infinite' }} />
<div className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
style={{ background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)', filter: 'blur(130px)', animation: 'mesh-float-3 14s ease-in-out infinite' }} />
<div className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
style={{ background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)', filter: 'blur(140px)', animation: 'mesh-float-4 20s ease-in-out infinite' }} />
<div className="absolute inset-0 opacity-[0.12] mix-blend-overlay" style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`, backgroundSize: '128px 128px' }} />
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)' }} />
</div>
)}
<div
className={
isActive
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
: 'w-full h-full relative'
}
style={{
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
transform: isActive ? 'translateZ(0)' : 'none',
}}
>
{isActive && iframeUrl ? (
<iframe
src={iframeUrl}
name="record-mode-iframe"
className="w-full h-full border-0 block"
style={{
backgroundColor: '#050505',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
height: '100%',
width: '100%',
}}
/>
) : (
<div
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
style={{
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
filter: isBlurry ? 'blur(4px)' : 'none',
willChange: 'transform, filter',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden',
}}
className={
isActive
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
: 'transition-all duration-700'
}
>
<div className={isActive ? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate' : 'w-full h-full'}
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}>
{isActive && (
<>
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
<div className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))', animation: 'pulse-ring 4s ease-in-out infinite' }} />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
</>
)}
<div className={isActive ? "w-full h-full rounded-[3rem] overflow-hidden relative" : "w-full h-full relative"}
style={{
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
transform: isActive ? 'translateZ(0)' : 'none'
}}>
{isActive && iframeUrl ? (
<iframe
src={iframeUrl}
name="record-mode-iframe"
className="w-full h-full border-0 block"
style={{
backgroundColor: '#050505',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
height: '100%',
width: '100%'
}}
/>
) : (
<div className={isActive ? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700' : 'transition-all duration-700'}>
{children}
</div>
)}
</div>
</div>
{children}
</div>
)}
</div>
</div>
</div>
<style jsx global>{`
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
@@ -182,8 +252,10 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
`}</style>
</div>
</>
);
`,
}}
/>
</div>
</>
);
}

View File

@@ -2,62 +2,65 @@
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import { useSearchParams } from 'next/navigation';
import { FeedbackOverlay } from '@mintel/next-feedback';
import { FeedbackOverlay } from '@mintel/next-feedback/FeedbackOverlay';
import { RecordModeOverlay } from './RecordModeOverlay';
import { PickingHelper } from './PickingHelper';
import { config } from '@/lib/config';
export function ToolCoordinator({ isEmbedded: isEmbeddedProp }: { isEmbedded?: boolean }) {
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive } = useRecordMode();
const [isEmbedded, setIsEmbedded] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const embedded =
isEmbeddedProp ||
window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
(window.self !== window.top);
setIsEmbedded(embedded);
}, [isEmbeddedProp]);
if (!mounted) return null;
// ABSOLUTE Priority 1: Inside Iframe - ONLY Rendering PickingHelper
if (isEmbedded) {
return <PickingHelper />;
}
// ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED
if (isActive) {
return <RecordModeOverlay />;
}
// Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED
if (isFeedbackActive) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
);
}
// Baseline: Both toggle buttons (inactive state)
// Only render if neither is active to prevent any overlapping residues
// IMPORTANT: FeedbackOverlay must be rendered with isActive={false} to provide the toggle button,
// but only if Record Mode is not active.
return (
<div className="feedback-ui-ignore">
{config.feedbackEnabled && (
<FeedbackOverlay
isActive={false}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
<RecordModeOverlay />
</div>
);
interface ToolCoordinatorProps {
isEmbedded?: boolean;
feedbackEnabled?: boolean;
}
export function ToolCoordinator({
isEmbedded: isEmbeddedProp,
feedbackEnabled = false,
}: ToolCoordinatorProps) {
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
useRecordMode();
const [isEmbedded, setIsEmbedded] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const embedded =
isEmbeddedProp ||
window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top;
setIsEmbedded(embedded);
}, [isEmbeddedProp]);
if (!mounted) return null;
// Nothing enabled → render nothing
if (!feedbackEnabled && !isEnabled) return null;
// Iframe → only PickingHelper
if (isEmbedded) return <PickingHelper />;
// Record Mode active and enabled
if (isActive && isEnabled) return <RecordModeOverlay />;
// Feedback active and enabled
if (isFeedbackActive && feedbackEnabled) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
);
}
// Baseline: toggle buttons
return (
<div className="feedback-ui-ignore">
{feedbackEnabled && (
<FeedbackOverlay
isActive={false}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
{isEnabled && <RecordModeOverlay />}
</div>
);
}

View File

@@ -24,15 +24,9 @@ image="https://www.zdf.de/assets/bundestag-berlin-118~1280x720?cb=1741856505967"
### Warum Kabelhersteller jetzt durchstarten sollten
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden und das gelingt nur mit leistungsfähigen Kabeln.
Die folgenden Trends sind für uns besonders relevant:
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br />
</strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
- <strong>Dezentralisierung der Energieversorgung:<br />
</strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br />
</strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /></strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
- <strong>Dezentralisierung der Energieversorgung:<br /></strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br /></strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind so wie die, die wir bei **KLZ** liefern.
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.

File diff suppressed because it is too large Load Diff

View File

@@ -43,34 +43,7 @@ collections:
hidden: true
schema:
name: products_translations
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
hidden: false
icon: feedback
singleton: false
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
hidden: false
icon: comment
singleton: false
versioning: false
schema:
name: visual_feedback_comments
fields:
# contact_submissions
- collection: contact_submissions
@@ -235,244 +208,7 @@ fields:
is_primary_key: true
has_auto_increment: true
# visual_feedback (from current snapshot)
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
field: id
hidden: true
sort: 1
schema:
name: id
table: visual_feedback
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
display: labels
interface: select-dropdown
sort: 2
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
is_nullable: true
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
display: labels
interface: select-dropdown
sort: 3
schema:
name: type
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
interface: input-multiline
sort: 4
schema:
name: text
table: visual_feedback
data_type: text
is_nullable: true
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
interface: input
readonly: true
sort: 5
schema:
name: url
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
interface: group-detail
sort: 6
special:
- alias
- no-data
- group
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
interface: input
sort: 1
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
interface: input
readonly: true
sort: 2
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
interface: group-detail
sort: 7
special:
- alias
- no-data
- group
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
interface: input
readonly: true
sort: 1
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
interface: input
sort: 2
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
interface: input
sort: 3
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
interface: datetime
readonly: true
sort: 8
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
field: feedback_id
interface: select-relational
sort: 2
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields:
- collection: directus_activity
@@ -488,17 +224,4 @@ systemFields:
schema:
is_indexed: true
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
many_collection: visual_feedback_comments
many_field: feedback_id
one_collection: visual_feedback
one_field: null

View File

@@ -25,9 +25,11 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
- "traefik.docker.network=infra"
directus-cms:
klz-cms:
container_name: klz-cms-dev
restart: "no"
ports:
- "8055:8055"
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
@@ -39,5 +41,5 @@ services:
klz-db:
restart: "no"
gatekeeper:
klz-gatekeeper:
restart: "no"

View File

@@ -9,53 +9,57 @@ services:
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: unless-stopped
networks:
- default
- infra
default:
infra:
aliases:
- klz.localhost
env_file:
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
# HTTPS router (Standard)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`, `/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`, `/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`.*sitemap.*`))"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Authentication Middleware (ForwardAuth)
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME:-klz-cables}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
# Rate Limit Middleware
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
healthcheck:
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
interval: 15s
@@ -63,14 +67,14 @@ services:
retries: 3
start_period: 45s
gatekeeper:
klz-gatekeeper:
profiles: [ "gatekeeper" ]
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
restart: unless-stopped
networks:
infra:
aliases:
- ${PROJECT_NAME:-klz-cables}-gatekeeper
- ${PROJECT_NAME:-klz}-gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -84,15 +88,15 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=infra"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus-cms:
klz-cms:
image: registry.infra.mintel.me/mintel/directus:latest
restart: unless-stopped
command: [ "node", "cli.js", "start" ]
@@ -108,7 +112,7 @@ services:
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
HOST: '0.0.0.0'
@@ -123,13 +127,16 @@ services:
disable: true
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-production-cms.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-production-cms.entrypoints=web"
- "traefik.http.routers.klz-production-cms.priority=5000"
- "traefik.http.routers.klz-production-cms.tls=false"
- "traefik.http.routers.klz-production-cms.service=klz-production-cms-svc"
- "traefik.http.services.klz-production-cms-svc.loadbalancer.server.port=8055"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
- "caddy.reverse_proxy={{upstreams 8055}}"
klz-db:
image: postgres:15-alpine
restart: unless-stopped
@@ -138,7 +145,7 @@ services:
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:

View File

@@ -1,11 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
3:17 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
3:27 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
✖ 2 problems (0 errors, 2 warnings)

View File

@@ -9,11 +9,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
const locale =
typeof rawLocale === 'string' && supportedLocales.includes(rawLocale) ? rawLocale : 'en';
// Log to Sentry if we had to fallback, as it might indicate a routing issue
// Silent fallback for missing locales to support internal requests (e.g. OG generation)
if (!rawLocale || !supportedLocales.includes(rawLocale as string)) {
console.warn(
`[i18n] Invalid or missing requestLocale received: "${rawLocale}". Falling back to "en".`,
);
// console.debug(`[i18n] Fallback to "en" for locale: "${rawLocale}"`);
}
return {

View File

@@ -15,6 +15,11 @@ function createConfig() {
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
console.log('[Config] Initializing Toggles:', {
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
});
return {
env: env.NODE_ENV,
target,
@@ -23,6 +28,7 @@ function createConfig() {
isTesting: target === 'testing',
isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL,
baseUrl: env.NEXT_PUBLIC_BASE_URL,
@@ -144,6 +150,9 @@ export const config = {
get feedbackEnabled() {
return getConfig().feedbackEnabled;
},
get recordModeEnabled() {
return getConfig().recordModeEnabled;
},
get infraCMS() {
return getConfig().infraCMS;
},

View File

@@ -1,6 +1,18 @@
import { z } from 'zod';
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils';
/**
* Robust boolean preprocessor for environment variables.
* Handles strings 'true'/'false' and actual booleans.
*/
const booleanSchema = z.preprocess((val) => {
if (typeof val === 'string') {
if (val.toLowerCase() === 'true') return true;
if (val.toLowerCase() === 'false') return false;
}
return val;
}, z.boolean());
/**
* Environment variable schema.
* Extends the default Mintel environment schema.
@@ -11,14 +23,9 @@ const envExtension = {
// Gatekeeper specifics not in base
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
GATEKEEPER_BYPASS_ENABLED: booleanSchema.default(false),
NEXT_PUBLIC_FEEDBACK_ENABLED: booleanSchema.default(false),
NEXT_PUBLIC_RECORD_MODE_ENABLED: booleanSchema.default(false),
INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(),

View File

@@ -1,132 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs at line 2.
(Use `node --trace-warnings ...` to show where the warning was created)
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/commitlint.config.cjs at line 2.
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/postcss.config.cjs at line 2.
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/tailwind.config.cjs at line 2.
/Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs
3:14 error A `require()` style import is forbidden @typescript-eslint/no-require-imports
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/[slug]/page.tsx
2:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
4:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
4:41 warning 'LOGO_URL' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/page.tsx
63:15 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
148:25 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/app/[locale]/layout.tsx
81:12 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/page.tsx
70:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
74:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
75:12 warning 'key' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/products/[...slug]/page.tsx
1:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
3:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/errors/api/relay/route.ts
28:11 warning 'header' is assigned a value but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/CMSConnectivityNotice.tsx
4:34 warning 'Database' is defined but never used @typescript-eslint/no-unused-vars
8:10 warning 'status' is assigned a value but never used @typescript-eslint/no-unused-vars
35:16 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Header.tsx
36:7 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Header.tsx:36:7
34 | useEffect(() => {
35 | if (isMobileMenuOpen) {
> 36 | setIsMobileMenuOpen(false);
| ^^^^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
37 | }
38 | }, [pathname, isMobileMenuOpen]);
39 | react-hooks/set-state-in-effect
116:37 warning 'idx' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx
24:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:24:5
22 |
23 | useEffect(() => {
> 24 | setMounted(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
25 | return () => setMounted(false);
26 | }, []);
27 | react-hooks/set-state-in-effect
62:9 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:62:9
60 | const index = parseInt(photoParam, 10);
61 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 62 | setCurrentIndex(index);
| ^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
63 | }
64 | }
65 | }, [searchParams, images.length]); react-hooks/set-state-in-effect
/Users/marcmintel/Projects/klz-2026/components/OGImageTemplate.tsx
49:11 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
30:38 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx:30:38
28 | const index = parseInt(photoParam, 10);
29 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 30 | if (lightboxIndex !== index) setLightboxIndex(index);
| ^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
31 | if (!lightboxOpen) setLightboxOpen(true);
32 | }
33 | } react-hooks/set-state-in-effect
/Users/marcmintel/Projects/klz-2026/components/home/RecentPosts.tsx
37:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/lib/config.ts
5:10 warning 'env' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/lib/mail/mailer.ts
4:10 warning 'ReactElement' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/middleware.ts
2:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
33:12 warning 'publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
✖ 27 problems (1 error, 26 warnings)
ELIFECYCLE Command failed with exit code 1.

View File

@@ -1,65 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
/Users/marcmintel/Projects/klz-2026/app/[locale]/layout.tsx
81:12 warning '_e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/page.tsx
70:12 warning '_err' is defined but never used @typescript-eslint/no-unused-vars
74:14 warning '_e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/errors/api/relay/route.ts
28:11 warning '_header' is assigned a value but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/CMSConnectivityNotice.tsx
8:10 warning '_status' is assigned a value but never used @typescript-eslint/no-unused-vars
35:16 warning '_err' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx
24:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:24:5
22 |
23 | useEffect(() => {
> 24 | setMounted(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
25 | return () => setMounted(false);
26 | }, []); // eslint-disable-line react-hooks/set-state-in-effect
27 | react-hooks/set-state-in-effect
26:11 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
62:9 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:62:9
60 | const index = parseInt(photoParam, 10);
61 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 62 | setCurrentIndex(index);
| ^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
63 | }
64 | }
65 | }, [searchParams, images.length]); // eslint-disable-line react-hooks/set-state-in-effect react-hooks/set-state-in-effect
65:38 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
3:17 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
3:27 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/middleware.ts
33:12 warning '_publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
✖ 13 problems (0 errors, 13 warnings)
0 errors and 2 warnings potentially fixable with the `--fix` option.

View File

@@ -19,7 +19,7 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/stats') ||
pathname.startsWith('/errors') ||
pathname.startsWith('/health') ||
pathname.startsWith('/api/og') ||
pathname.includes('/api/og') ||
pathname.includes('opengraph-image')
) {
return;
@@ -42,11 +42,8 @@ export default function middleware(request: NextRequest) {
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
const hostHeader =
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
hostHeader.split(':');
urlObj.protocol = proto;
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
effectiveRequest = new NextRequest(urlObj, {
headers: request.headers,
@@ -55,13 +52,35 @@ export default function middleware(request: NextRequest) {
});
console.log(
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
);
}
try {
// Apply internationalization middleware
const response = intlMiddleware(effectiveRequest);
// Upgrade 307 (Temporary Redirect) to 308 (Permanent Redirect)
// This improves compatibility with scanners (Website Carbon, PageSpeed) and SEO.
if (response.status === 307) {
const location = response.headers.get('Location');
if (location) {
const url = new URL(location, request.url);
return Response.redirect(url, 308);
}
}
// Allow iframe embedding from recorder domains
const referer = headers.get('referer') || '';
const recorderDomains = ['recorder.localhost', 'recorder.mintel.me'];
const isRecorderRequest = recorderDomains.some((domain) => referer.includes(domain));
if (isRecorderRequest) {
response.headers.delete('x-frame-options');
response.headers.delete('content-security-policy');
response.headers.set('Access-Control-Allow-Origin', '*');
}
return response;
} catch (error) {
console.error(
@@ -73,6 +92,7 @@ export default function middleware(request: NextRequest) {
}
export const config = {
// Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/', '/(de|en)/:path*'],
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)',
],
};

View File

@@ -1,10 +1,18 @@
import createNextIntlPlugin from 'next-intl/plugin';
import withMintelConfig from '@mintel/next-config';
const withNextIntl = createNextIntlPlugin();
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
/** @type {import('next').NextConfig} */
const nextConfig = {
onDemandEntries: {
// Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000,
},
logging: {
fetches: {
fullUrl: true,
},
},
output: 'standalone',
async redirects() {
return [
@@ -342,6 +350,4 @@ const nextConfig = {
},
};
const nextIntlConfig = withNextIntl(nextConfig);
export default withMintelConfig(nextIntlConfig);
export default withMintelConfig(nextConfig);

View File

@@ -78,8 +78,8 @@
"vitest": "^4.0.16"
},
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app directus-cms klz-db gatekeeper",
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d directus-cms klz-db gatekeeper",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app klz-cms klz-db klz-gatekeeper",
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
@@ -88,7 +88,7 @@
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"check:og": "tsx scripts/check-og-images.ts",
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
@@ -127,4 +127,4 @@
"lucide-react": "^0.563.0",
"remotion": "^4.0.421"
}
}
}

View File

@@ -1,107 +1,127 @@
import React, { useMemo } from 'react';
import { AbsoluteFill, useVideoConfig, useCurrentFrame, interpolate, spring, Easing } from 'remotion';
import {
AbsoluteFill,
useVideoConfig,
useCurrentFrame,
interpolate,
spring,
Easing,
} from 'remotion';
import { RecordingSession, RecordEvent } from '../types/record-mode';
export const WebsiteVideo: React.FC<{
session: RecordingSession | null;
siteUrl: string;
session: RecordingSession | null;
siteUrl: string;
}> = ({ session, siteUrl }) => {
const { fps, width, height, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const { fps, width, height, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
if (!session || !session.events.length) {
return (
<AbsoluteFill style={{ backgroundColor: 'black', color: 'white', justifyContent: 'center', alignItems: 'center' }}>
No session data found.
</AbsoluteFill>
);
}
const sortedEvents = useMemo(() => {
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
}, [session]);
const elapsedTimeMs = (frame / fps) * 1000;
// --- Interpolation Logic ---
// 1. Find the current window (between which two events are we?)
const nextEventIndex = sortedEvents.findIndex(e => e.timestamp > elapsedTimeMs);
let currentEventIndex;
if (nextEventIndex === -1) {
// We are past the last event, stay at the end
currentEventIndex = sortedEvents.length - 1;
} else {
currentEventIndex = Math.max(0, nextEventIndex - 1);
}
const currentEvent = sortedEvents[currentEventIndex];
// If there is no next event, we just stay at current (next=current)
const nextEvent = (nextEventIndex !== -1) ? sortedEvents[nextEventIndex] : currentEvent;
// 2. Calculate Progress between events
const gap = nextEvent.timestamp - currentEvent.timestamp;
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
// 3. Calculate Cursor Position from Rects
const getCenter = (event: RecordEvent) => {
if (event.rect) {
return {
x: event.rect.x + event.rect.width / 2,
y: event.rect.y + event.rect.height / 2
};
}
return { x: width / 2, y: height / 2 };
};
const p1 = getCenter(currentEvent);
const p2 = getCenter(nextEvent);
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
// 4. Zoom & Blur
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
const sortedEvents = useMemo(() => {
if (!session) return [];
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
}, [session]);
if (!session || !session.events.length) {
return (
<AbsoluteFill style={{ backgroundColor: '#000' }}>
<div style={{
width: '100%',
height: '100%',
position: 'relative',
transform: `scale(${zoom})`,
transformOrigin: `${cursorX}px ${cursorY}px`,
filter: isBlurry ? 'blur(8px)' : 'none',
transition: 'filter 0.1s ease-out'
}}>
<iframe
src={siteUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
title="Website"
/>
</div>
{/* Visual Cursor */}
<div style={{
position: 'absolute',
left: cursorX,
top: cursorY,
width: 34, height: 34,
backgroundColor: 'white',
borderRadius: '50%',
border: '3px solid black',
boxShadow: '0 4px 15px rgba(0,0,0,0.4)',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100
}}>
<div style={{ width: 12, height: 12, backgroundColor: '#3b82f6', borderRadius: '50%' }} />
</div>
</AbsoluteFill>
<AbsoluteFill
style={{
backgroundColor: 'black',
color: 'white',
justifyContent: 'center',
alignItems: 'center',
}}
>
No session data found.
</AbsoluteFill>
);
}
const elapsedTimeMs = (frame / fps) * 1000;
// --- Interpolation Logic ---
// 1. Find the current window (between which two events are we?)
const nextEventIndex = sortedEvents.findIndex((e) => e.timestamp > elapsedTimeMs);
let currentEventIndex;
if (nextEventIndex === -1) {
// We are past the last event, stay at the end
currentEventIndex = sortedEvents.length - 1;
} else {
currentEventIndex = Math.max(0, nextEventIndex - 1);
}
const currentEvent = sortedEvents[currentEventIndex];
// If there is no next event, we just stay at current (next=current)
const nextEvent = nextEventIndex !== -1 ? sortedEvents[nextEventIndex] : currentEvent;
// 2. Calculate Progress between events
const gap = nextEvent.timestamp - currentEvent.timestamp;
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
// 3. Calculate Cursor Position from Rects
const getCenter = (event: RecordEvent) => {
if (event.rect) {
return {
x: event.rect.x + event.rect.width / 2,
y: event.rect.y + event.rect.height / 2,
};
}
return { x: width / 2, y: height / 2 };
};
const p1 = getCenter(currentEvent);
const p2 = getCenter(nextEvent);
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
// 4. Zoom & Blur
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
return (
<AbsoluteFill style={{ backgroundColor: '#000' }}>
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
transform: `scale(${zoom})`,
transformOrigin: `${cursorX}px ${cursorY}px`,
filter: isBlurry ? 'blur(8px)' : 'none',
transition: 'filter 0.1s ease-out',
}}
>
<iframe
src={siteUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
title="Website"
/>
</div>
{/* Visual Cursor */}
<div
style={{
position: 'absolute',
left: cursorX,
top: cursorY,
width: 34,
height: 34,
backgroundColor: 'white',
borderRadius: '50%',
border: '3px solid black',
boxShadow: '0 4px 15px rgba(0,0,0,0.4)',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
}}
>
<div style={{ width: 12, height: 12, backgroundColor: '#3b82f6', borderRadius: '50%' }} />
</div>
</AbsoluteFill>
);
};

View File

@@ -22,16 +22,20 @@ async function verifyImage(path: string): Promise<boolean> {
console.log(`Checking ${url}...`);
const body = await response.clone().text();
const contentType = response.headers.get('content-type');
if (response.status !== 200) {
throw new Error(`Status: ${response.status}`);
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
console.log(` Status Text: ${response.statusText}`);
throw new Error(
`Status: ${response.status}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('image/png')) {
const body = await response.text();
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
throw new Error(
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 500).replace(/\n/g, ' ')}...`,
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
);
}

View File

@@ -13,7 +13,7 @@ PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed
case $ENV in
local)
CONTAINER=$(docker compose ps -q directus)
CONTAINER=$(docker compose ps -q klz-cms)
if [ -z "$CONTAINER" ]; then
echo "❌ Local directus container not found."
exit 1

View File

@@ -0,0 +1,59 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { readUsers, updateUser } from '@directus/sdk';
import { config } from '../lib/config';
async function fixToken() {
console.log('🔑 Ensuring Directus Admin Token is set...');
try {
// 1. Authenticate with credentials
await ensureAuthenticated();
// 2. Find admin user
const users = await client.request(
readUsers({
filter: {
email: { _eq: config.directus.adminEmail },
},
}),
);
if (!users || users.length === 0) {
console.error(`❌ Could not find user with email ${config.directus.adminEmail}`);
process.exit(1);
}
const admin = users[0];
const targetToken = config.directus.token;
if (!targetToken) {
console.error('❌ No DIRECTUS_API_TOKEN configured in environment.');
process.exit(1);
}
if (admin.token === targetToken) {
console.log('✅ Token is already correctly set.');
return;
}
// 3. Update token
console.log(`📡 Updating token for ${config.directus.adminEmail}...`);
await client.request(
updateUser(admin.id, {
token: targetToken,
}),
);
console.log('✨ Token successfully updated!');
} catch (error: any) {
console.error('❌ Error fixing token:');
if (error.errors) {
console.error(JSON.stringify(error.errors, null, 2));
} else {
console.error(error.message || error);
}
process.exit(1);
}
}
fixToken();

View File

@@ -0,0 +1,68 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { createCollection, createField } from '@directus/sdk';
async function setupSchema() {
console.log('🏗️ Manually creating contact_submissions collection...');
try {
// 1. Authenticate (using token from config)
await ensureAuthenticated();
// 2. Create collection
console.log('📡 Creating "contact_submissions" collection...');
await client.request(
createCollection({
collection: 'contact_submissions',
meta: {
icon: 'contact_mail',
color: '#002b49',
display_template: '{{name}} | {{email}}',
},
schema: {
name: 'contact_submissions',
},
}),
);
// 3. Create fields
console.log('📡 Creating fields for "contact_submissions"...');
// name
await client.request(
createField('contact_submissions', {
field: 'name',
type: 'string',
meta: { interface: 'input' },
}),
);
// email
await client.request(
createField('contact_submissions', {
field: 'email',
type: 'string',
meta: { interface: 'input' },
}),
);
// message
await client.request(
createField('contact_submissions', {
field: 'message',
type: 'text',
meta: { interface: 'textarea' },
}),
);
console.log('✨ Collection and fields created successfully!');
} catch (error: any) {
console.error('❌ Error creating schema:');
if (error.errors) {
console.error(JSON.stringify(error.errors, null, 2));
} else {
console.error(error.message || error);
}
}
}
setupSchema();

205
scripts/merge-umami-data.ts Normal file
View File

@@ -0,0 +1,205 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CSV_PATHS = [
'/Users/marcmintel/Downloads/pages.csv',
'/Users/marcmintel/Downloads/pages(1).csv',
'/Users/marcmintel/Downloads/pages(2).csv',
];
const JSON_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-merged.json');
const SQL_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-new.sql');
const WEBSITE_ID = '59a7db94-0100-4c7e-98ef-99f45b17f9c3';
const HOSTNAME = 'klz-cables.com';
function parseCSV(content: string) {
const lines = content.split('\n');
if (lines.length === 0) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
const data = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
// Simple CSV parser that handles quotes
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < lines[i].length; j++) {
const char = lines[i][j];
if (char === '"') inQuotes = !inQuotes;
else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
const row: any = {};
headers.forEach((header, index) => {
row[header] = values[index]?.replace(/^"|"$/g, '');
});
data.push(row);
}
return data;
}
function normalizeURL(url: string) {
if (!url) return '/';
if (url.startsWith('http')) {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
return url.startsWith('/') ? url : `/${url}`;
}
async function mergeData() {
console.log('Reading CSVs...');
const aggregatedData: Record<string, { views: number; visitors: number; title: string }> = {};
for (const csvPath of CSV_PATHS) {
if (!fs.existsSync(csvPath)) {
console.warn(`File not found: ${csvPath}`);
continue;
}
const csvContent = fs.readFileSync(csvPath, 'utf-8');
const csvData = parseCSV(csvContent);
for (const row of csvData) {
const url = normalizeURL(row.URL);
const views = parseInt(row.Views) || 0;
const visitors = parseInt(row.Visitors) || 0;
const title = row.Title || '';
if (!aggregatedData[url]) {
aggregatedData[url] = { views, visitors, title };
} else {
aggregatedData[url].views = Math.max(aggregatedData[url].views, views);
aggregatedData[url].visitors = Math.max(aggregatedData[url].visitors, visitors);
if (!aggregatedData[url].title && title) {
aggregatedData[url].title = title;
}
}
}
}
const jsonEvents = [];
const sqlStatements = [];
// Spread data across the whole period since early 2025 launch
const START_DATE = new Date('2025-01-01T08:00:00Z');
const END_DATE = new Date('2026-02-13T20:00:00Z');
const startTs = START_DATE.getTime();
const endTs = END_DATE.getTime();
const totalDays = Math.ceil((endTs - startTs) / (1000 * 60 * 60 * 24));
// Cleanup for the target period
sqlStatements.push(`-- Cleanup previous artificial imports (Full Year 2025 and 2026 until now)
DELETE FROM website_event WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59' AND hostname = '${HOSTNAME}';
DELETE FROM session WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59';
`);
// Helper for weighted random date selection
function getRandomWeightedDate() {
while (true) {
const randomDays = Math.random() * totalDays;
const date = new Date(startTs + randomDays * 24 * 60 * 60 * 1000);
// 1. Growth Factor (0.2 at start to 1.0 at end)
const growthWeight = 0.2 + (randomDays / totalDays) * 0.8;
// 2. Weekend Factor (30% traffic on weekends)
const dayOfWeek = date.getDay();
const weekendWeight = dayOfWeek === 0 || dayOfWeek === 6 ? 0.3 : 1.0;
// 3. Seasonality (simple sine wave)
const month = date.getMonth();
const seasonWeight = 0.8 + Math.sin((month / 12) * Math.PI * 2) * 0.2;
// Combined weight
const combinedWeight = growthWeight * weekendWeight * seasonWeight;
// Pick based on weight
if (Math.random() < combinedWeight) {
// Return timestamp with random hour/minute
date.setHours(Math.floor(Math.random() * 12) + 8); // Business hours mostly
date.setMinutes(Math.floor(Math.random() * 60));
return date;
}
}
}
const urls = Object.keys(aggregatedData);
console.log(`Processing ${urls.length} aggregated URLs...`);
for (const url of urls) {
const { views, visitors, title } = aggregatedData[url];
if (views === 0) continue;
// We distribute views across visitors
const sessionData = [];
for (let v = 0; v < (visitors || 1); v++) {
const sessionId = crypto.randomUUID();
const visitId = crypto.randomUUID();
const sessionDate = getRandomWeightedDate();
const dateStr = sessionDate.toISOString().replace('T', ' ').split('.')[0];
sessionData.push({ sessionId, visitId, date: sessionDate });
sqlStatements.push(`INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
VALUES ('${sessionId}', '${WEBSITE_ID}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '${dateStr}')
ON CONFLICT (session_id) DO NOTHING;`);
}
// Distribute views across these sessions
for (let i = 0; i < views; i++) {
const sIdx = i % sessionData.length;
const session = sessionData[sIdx];
const sessionId = session.sessionId;
const visitId = session.visitId;
const eventId = crypto.randomUUID();
// Event date should be close to session date
const eventDate = new Date(session.date.getTime() + Math.random() * 1000 * 60 * 30); // within 30 mins
const timestamp = eventDate.toISOString();
const dateStr = timestamp.replace('T', ' ').split('.')[0];
// JSON Format
jsonEvents.push({
website_id: WEBSITE_ID,
hostname: HOSTNAME,
path: url,
referrer: '',
event_name: null,
pageview: true,
session: true,
duration: Math.floor(Math.random() * 120) + 10,
created_at: timestamp,
});
// SQL Format
sqlStatements.push(`INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
VALUES ('${eventId}', '${WEBSITE_ID}', '${sessionId}', '${dateStr}', '${url}', '', '', '', '', '${title.replace(/'/g, "''")}', 1, NULL, '${visitId}', '${HOSTNAME}');`);
}
}
console.log(`Writing ${jsonEvents.length} events to ${JSON_OUTPUT_PATH}...`);
fs.writeFileSync(JSON_OUTPUT_PATH, JSON.stringify(jsonEvents, null, 2));
console.log(`Writing SQL statements to ${SQL_OUTPUT_PATH}...`);
fs.writeFileSync(SQL_OUTPUT_PATH, sqlStatements.join('\n'));
console.log('✅ Refined Restoration Script complete!');
}
mergeData().catch(console.error);

View File

@@ -49,7 +49,7 @@ esac
# Detect local container
echo "🔍 Detecting local database..."
# Use a more robust way to find the container if multiple projects exist locally
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-directus-db)
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
exit 1

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +0,0 @@
> klz-cables-nextjs@1.0.0 typecheck /Users/marcmintel/Projects/klz-2026
> tsc --noEmit